Some more tools around Android icons
All checks were successful
/ publish (push) Successful in 1m48s
All checks were successful
/ publish (push) Successful in 1m48s
This commit is contained in:
parent
7f6dcac154
commit
c7ff8597aa
11
CLAUDE.md
Normal file
11
CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Webtools
|
||||||
|
|
||||||
|
This is a static site for a set of tools available on the web. The root of the site is located in "site", with index.html holding a list of available tools. Within each subdirectory of "site" is a separate tool. Each tool is self-contained with it's own index.html, CSS and JavaScript. Multipes of these files can be added if necessary. Note that each HTML has a standard header, with a typical title convension and importing "pico.min.css".
|
||||||
|
|
||||||
|
Some tools can include a WASM file for more complex tasks. The WASM targets are built from Go files, with each cmd located as a sub directory in the "cmds" directory. Each one is built as a standalone WASM target.
|
||||||
|
|
||||||
|
To build:
|
||||||
|
|
||||||
|
- Run `build.wasm` to build the WASM targets. Additional targets will need to be added to this Make target
|
||||||
|
- Run `build` to build both the WASM and prepare the site. The site will be available in a "target" directory.
|
||||||
|
- Run `dev` to build the site and run a dev HTML server to test the site on port 8000. The dev server can be killed by sending a SIGINT signal.
|
||||||
1
Makefile
1
Makefile
|
|
@ -14,6 +14,7 @@ build.wasm:
|
||||||
GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks
|
GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks
|
||||||
GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate
|
GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate
|
||||||
GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps
|
GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps
|
||||||
|
GOOS=js GOARCH=wasm go build -o target/wasm/android-icons.wasm ./cmds/android-icons
|
||||||
cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/.
|
cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/.
|
||||||
|
|
||||||
.Phony: build.site
|
.Phony: build.site
|
||||||
|
|
|
||||||
144
cmds/android-icons/main.go
Normal file
144
cmds/android-icons/main.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type density struct {
|
||||||
|
dir string
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
var densities = []density{
|
||||||
|
{"mipmap-mdpi", 48},
|
||||||
|
{"mipmap-hdpi", 72},
|
||||||
|
{"mipmap-xhdpi", 96},
|
||||||
|
{"mipmap-xxhdpi", 144},
|
||||||
|
{"mipmap-xxxhdpi", 192},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
js.Global().Set("prepareZip", js.FuncOf(prepareZip))
|
||||||
|
<-make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareZip(this js.Value, args []js.Value) interface{} {
|
||||||
|
// args[0] is an array of {name: string, data: string (base64 PNG data)}
|
||||||
|
files := args[0]
|
||||||
|
length := files.Length()
|
||||||
|
|
||||||
|
if length == 0 {
|
||||||
|
js.Global().Call("alert", "No files selected.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
file := files.Index(i)
|
||||||
|
name := file.Get("name").String()
|
||||||
|
dataURL := file.Get("data").String()
|
||||||
|
|
||||||
|
// Strip data URL prefix (data:image/png;base64,)
|
||||||
|
commaIdx := strings.Index(dataURL, ",")
|
||||||
|
if commaIdx < 0 {
|
||||||
|
setStatus(fmt.Sprintf("Invalid data for %s", name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b64Data := dataURL[commaIdx+1:]
|
||||||
|
|
||||||
|
imgData, err := base64.StdEncoding.DecodeString(b64Data)
|
||||||
|
if err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to decode %s: %v", name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := png.Decode(bytes.NewReader(imgData))
|
||||||
|
if err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to decode PNG %s: %v", name, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip extension from name for the base name
|
||||||
|
baseName := name
|
||||||
|
if strings.HasSuffix(strings.ToLower(baseName), ".png") {
|
||||||
|
baseName = baseName[:len(baseName)-4]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range densities {
|
||||||
|
resized := resizeImage(src, d.size)
|
||||||
|
|
||||||
|
var pngBuf bytes.Buffer
|
||||||
|
if err := png.Encode(&pngBuf, resized); err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to encode %s/%s.png: %v", d.dir, baseName, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("%s/%s.png", d.dir, baseName)
|
||||||
|
w, err := zw.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to create zip entry %s: %v", path, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := w.Write(pngBuf.Bytes()); err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to write zip entry %s: %v", path, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
setStatus(fmt.Sprintf("Failed to finalize zip: %v", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert zip bytes to a JS Uint8Array and trigger download
|
||||||
|
zipBytes := buf.Bytes()
|
||||||
|
jsArray := js.Global().Get("Uint8Array").New(len(zipBytes))
|
||||||
|
js.CopyBytesToJS(jsArray, zipBytes)
|
||||||
|
|
||||||
|
// Create Blob and download
|
||||||
|
blobParts := js.Global().Get("Array").New()
|
||||||
|
blobParts.Call("push", jsArray)
|
||||||
|
blobOpts := js.Global().Get("Object").New()
|
||||||
|
blobOpts.Set("type", "application/zip")
|
||||||
|
blob := js.Global().Get("Blob").New(blobParts, blobOpts)
|
||||||
|
|
||||||
|
url := js.Global().Get("URL").Call("createObjectURL", blob)
|
||||||
|
anchor := js.Global().Get("document").Call("createElement", "a")
|
||||||
|
anchor.Set("href", url)
|
||||||
|
anchor.Set("download", "android-icons.zip")
|
||||||
|
js.Global().Get("document").Get("body").Call("appendChild", anchor)
|
||||||
|
anchor.Call("click")
|
||||||
|
anchor.Call("remove")
|
||||||
|
js.Global().Get("URL").Call("revokeObjectURL", url)
|
||||||
|
|
||||||
|
setStatus(fmt.Sprintf("Done! Prepared icons for %d image(s).", length))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeImage(src image.Image, size int) image.Image {
|
||||||
|
dst := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatus(msg string) {
|
||||||
|
querySelector("#status").Set("innerText", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func querySelector(query string) js.Value {
|
||||||
|
return js.Global().Get("document").Call("querySelector", query)
|
||||||
|
}
|
||||||
7
go.mod
7
go.mod
|
|
@ -1,5 +1,8 @@
|
||||||
module github.com/lmika/webtools
|
module github.com/lmika/webtools
|
||||||
|
|
||||||
go 1.24.3
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/alecthomas/participle/v2 v2.1.4 // indirect
|
require (
|
||||||
|
github.com/alecthomas/participle/v2 v2.1.4 // indirect
|
||||||
|
golang.org/x/image v0.37.0 // indirect
|
||||||
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -1,2 +1,4 @@
|
||||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||||
|
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
|
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
|
|
|
||||||
34
site/android-icons/index.html
Normal file
34
site/android-icons/index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Android Icon Resizer - Tools</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="container">
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>Android Icon Resizer</h1>
|
||||||
|
<p>Resize PNG images to Android mipmap density buckets. Everything runs in your browser.</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
<label for="file-input">Select PNG files</label>
|
||||||
|
<input type="file" id="file-input" accept="image/png" multiple>
|
||||||
|
</div>
|
||||||
|
<div id="preview-area"></div>
|
||||||
|
<div id="circle-preview">
|
||||||
|
<h3>Circle Crop Preview</h3>
|
||||||
|
<canvas id="circle-canvas" width="192" height="192"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="prepare-btn" disabled>Prepare</button>
|
||||||
|
</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
</main>
|
||||||
|
<script src="main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
136
site/android-icons/main.js
Normal file
136
site/android-icons/main.js
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import "/wasm/wasm_exec.js";
|
||||||
|
|
||||||
|
const go = new Go();
|
||||||
|
let wasmReady = false;
|
||||||
|
|
||||||
|
WebAssembly.instantiateStreaming(fetch("/wasm/android-icons.wasm"), go.importObject)
|
||||||
|
.then((result) => {
|
||||||
|
go.run(result.instance);
|
||||||
|
wasmReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("file-input");
|
||||||
|
const previewArea = document.getElementById("preview-area");
|
||||||
|
const prepareBtn = document.getElementById("prepare-btn");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const circlePreview = document.getElementById("circle-preview");
|
||||||
|
const circleCanvas = document.getElementById("circle-canvas");
|
||||||
|
|
||||||
|
let loadedFiles = [];
|
||||||
|
|
||||||
|
function renderCirclePreview() {
|
||||||
|
if (loadedFiles.length === 0) {
|
||||||
|
circlePreview.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLower = (f) => f.name.toLowerCase();
|
||||||
|
|
||||||
|
let bgFile = loadedFiles.find((f) => /back/.test(nameLower(f)));
|
||||||
|
let fgFile = loadedFiles.find((f) => !/back/.test(nameLower(f)) && !/mono/.test(nameLower(f)));
|
||||||
|
|
||||||
|
// Fallbacks: if only one image or no "back" image, use the first file
|
||||||
|
if (!bgFile && !fgFile) {
|
||||||
|
bgFile = loadedFiles[0];
|
||||||
|
} else if (!bgFile) {
|
||||||
|
bgFile = fgFile;
|
||||||
|
fgFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = 192;
|
||||||
|
circleCanvas.width = size;
|
||||||
|
circleCanvas.height = size;
|
||||||
|
const ctx = circleCanvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// Clip to circle
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
const drawLayer = (src) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = resolve;
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
drawLayer(bgFile.data).then(() => {
|
||||||
|
if (fgFile && fgFile !== bgFile) {
|
||||||
|
return drawLayer(fgFile.data);
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw border ring
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2 - 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.strokeStyle = "rgba(128, 128, 128, 0.5)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
circlePreview.style.display = "block";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
loadedFiles = [];
|
||||||
|
previewArea.innerHTML = "";
|
||||||
|
circlePreview.style.display = "none";
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
prepareBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const dataURL = e.target.result;
|
||||||
|
loadedFiles.push({ name: file.name, data: dataURL });
|
||||||
|
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "preview-item";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = dataURL;
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = file.name;
|
||||||
|
|
||||||
|
item.appendChild(img);
|
||||||
|
item.appendChild(label);
|
||||||
|
previewArea.appendChild(item);
|
||||||
|
|
||||||
|
if (loadedFiles.length === files.length) {
|
||||||
|
prepareBtn.disabled = false;
|
||||||
|
renderCirclePreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
prepareBtn.addEventListener("click", () => {
|
||||||
|
if (!wasmReady) {
|
||||||
|
statusEl.textContent = "WASM module is still loading, please wait...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadedFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Preparing...";
|
||||||
|
// Pass file data to Go WASM
|
||||||
|
setTimeout(() => {
|
||||||
|
prepareZip(loadedFiles);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
54
site/android-icons/style.css
Normal file
54
site/android-icons/style.css
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#preview-area {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item img {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 96px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#circle-preview {
|
||||||
|
display: none;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#circle-preview h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#circle-canvas {
|
||||||
|
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 3px var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
50
site/gradient-image/index.html
Normal file
50
site/gradient-image/index.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gradient Image - Tools</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="container">
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>Gradient Image</h1>
|
||||||
|
<p>Generate gradient images as downloadable PNGs</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<main class="grid">
|
||||||
|
<section class="controls">
|
||||||
|
<label for="from-color">From Colour</label>
|
||||||
|
<input type="color" id="from-color" value="#ff0000">
|
||||||
|
|
||||||
|
<label for="to-color">To Colour</label>
|
||||||
|
<input type="color" id="to-color" value="#0000ff">
|
||||||
|
|
||||||
|
<label for="gradient-type">Gradient Type</label>
|
||||||
|
<select id="gradient-type">
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
<option value="radial">Radial</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="rotation">Rotation: <span id="rotation-value">0</span>°</label>
|
||||||
|
<input type="range" id="rotation" min="0" max="360" step="45" value="0">
|
||||||
|
|
||||||
|
<label for="image-size">Image Size</label>
|
||||||
|
<select id="image-size">
|
||||||
|
<option value="64">64 × 64</option>
|
||||||
|
<option value="128">128 × 128</option>
|
||||||
|
<option value="192">192 × 192</option>
|
||||||
|
<option value="256" selected>256 × 256</option>
|
||||||
|
<option value="512">512 × 512</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
<section class="preview">
|
||||||
|
<canvas id="preview-canvas" width="256" height="256"></canvas>
|
||||||
|
<button id="download-btn">Download</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
site/gradient-image/main.js
Normal file
70
site/gradient-image/main.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
const PREVIEW_SIZE = 256;
|
||||||
|
|
||||||
|
const fromColorEl = document.getElementById("from-color");
|
||||||
|
const toColorEl = document.getElementById("to-color");
|
||||||
|
const gradientTypeEl = document.getElementById("gradient-type");
|
||||||
|
const rotationEl = document.getElementById("rotation");
|
||||||
|
const rotationValueEl = document.getElementById("rotation-value");
|
||||||
|
const imageSizeEl = document.getElementById("image-size");
|
||||||
|
const canvas = document.getElementById("preview-canvas");
|
||||||
|
const downloadBtn = document.getElementById("download-btn");
|
||||||
|
|
||||||
|
function drawGradient(ctx, size) {
|
||||||
|
const fromColor = fromColorEl.value;
|
||||||
|
const toColor = toColorEl.value;
|
||||||
|
const type = gradientTypeEl.value;
|
||||||
|
const rotation = parseInt(rotationEl.value);
|
||||||
|
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
|
||||||
|
let gradient;
|
||||||
|
if (type === "radial") {
|
||||||
|
const radius = size / 2;
|
||||||
|
gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||||
|
} else {
|
||||||
|
const angle = (rotation * Math.PI) / 180;
|
||||||
|
const len = size / 2;
|
||||||
|
const dx = Math.cos(angle) * len;
|
||||||
|
const dy = Math.sin(angle) * len;
|
||||||
|
gradient = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
gradient.addColorStop(0, fromColor);
|
||||||
|
gradient.addColorStop(1, toColor);
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
canvas.width = PREVIEW_SIZE;
|
||||||
|
canvas.height = PREVIEW_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
drawGradient(ctx, PREVIEW_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const size = parseInt(imageSizeEl.value);
|
||||||
|
const offscreen = document.createElement("canvas");
|
||||||
|
offscreen.width = size;
|
||||||
|
offscreen.height = size;
|
||||||
|
const ctx = offscreen.getContext("2d");
|
||||||
|
drawGradient(ctx, size);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = `gradient-${size}x${size}.png`;
|
||||||
|
link.href = offscreen.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
fromColorEl.addEventListener("input", renderPreview);
|
||||||
|
toColorEl.addEventListener("input", renderPreview);
|
||||||
|
gradientTypeEl.addEventListener("input", renderPreview);
|
||||||
|
rotationEl.addEventListener("input", () => {
|
||||||
|
rotationValueEl.textContent = rotationEl.value;
|
||||||
|
renderPreview();
|
||||||
|
});
|
||||||
|
downloadBtn.addEventListener("click", download);
|
||||||
|
|
||||||
|
renderPreview();
|
||||||
22
site/gradient-image/style.css
Normal file
22
site/gradient-image/style.css
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-canvas {
|
||||||
|
width: 256px;
|
||||||
|
height: 256px;
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#download-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
32
site/image-inner-resize/index.html
Normal file
32
site/image-inner-resize/index.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Image Inner Resize - Tools</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="container">
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>Image Inner Resize</h1>
|
||||||
|
<p>Scale an image within its original dimensions, centered on a transparent background</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<main class="grid">
|
||||||
|
<section class="controls">
|
||||||
|
<label for="file-input">Select PNG file</label>
|
||||||
|
<input type="file" id="file-input" accept="image/png">
|
||||||
|
|
||||||
|
<label for="scale">Scale: <span id="scale-value">100</span>%</label>
|
||||||
|
<input type="range" id="scale" min="0" max="100" step="5" value="100">
|
||||||
|
</section>
|
||||||
|
<section class="preview">
|
||||||
|
<canvas id="preview-canvas"></canvas>
|
||||||
|
<button id="download-btn" disabled>Download</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
site/image-inner-resize/main.js
Normal file
57
site/image-inner-resize/main.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
const fileInput = document.getElementById("file-input");
|
||||||
|
const scaleEl = document.getElementById("scale");
|
||||||
|
const scaleValueEl = document.getElementById("scale-value");
|
||||||
|
const canvas = document.getElementById("preview-canvas");
|
||||||
|
const downloadBtn = document.getElementById("download-btn");
|
||||||
|
|
||||||
|
let sourceImage = null;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!sourceImage) return;
|
||||||
|
|
||||||
|
const w = sourceImage.naturalWidth;
|
||||||
|
const h = sourceImage.naturalHeight;
|
||||||
|
const scale = parseInt(scaleEl.value) / 100;
|
||||||
|
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const sw = w * scale;
|
||||||
|
const sh = h * scale;
|
||||||
|
const sx = (w - sw) / 2;
|
||||||
|
const sy = (h - sh) / 2;
|
||||||
|
|
||||||
|
ctx.drawImage(sourceImage, sx, sy, sw, sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
sourceImage = img;
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
scaleEl.addEventListener("input", () => {
|
||||||
|
scaleValueEl.textContent = scaleEl.value;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBtn.addEventListener("click", () => {
|
||||||
|
if (!sourceImage) return;
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "resized.png";
|
||||||
|
link.href = canvas.toDataURL("image/png");
|
||||||
|
link.click();
|
||||||
|
});
|
||||||
23
site/image-inner-resize/style.css
Normal file
23
site/image-inner-resize/style.css
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 512px;
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#download-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,9 @@
|
||||||
<li><a href="/mental-arithmatic/">Mental Arithmatic Game</a></li>
|
<li><a href="/mental-arithmatic/">Mental Arithmatic Game</a></li>
|
||||||
<li><a href="/neon-snake/">Neon Snake</a>: vibe-coded by Google Gemini</li>
|
<li><a href="/neon-snake/">Neon Snake</a>: vibe-coded by Google Gemini</li>
|
||||||
<li><a href="/hex-color/">Hex Color Converter</a></li>
|
<li><a href="/hex-color/">Hex Color Converter</a></li>
|
||||||
|
<li><a href="/android-icons/">Android Icon Resizer</a></li>
|
||||||
|
<li><a href="/gradient-image/">Gradient Image</a></li>
|
||||||
|
<li><a href="/image-inner-resize/">Image Inner Resize</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue