Files
2025-10-16 11:46:40 +02:00

483 lines
17 KiB
JavaScript

document.addEventListener("DOMContentLoaded", () => {
const generator = new PreviewGenerator();
generator.init();
});
class PreviewGenerator {
static DEBOUNCE_TIME_MS = 500;
constructor() {
/**
* @type HTMLCanvasElement
*/
this.previewCanvas = null;
/**
* @type HTMLCanvasElement
*/
this.targetCanvas = null;
/**
* @type CanvasRenderingContext2D
*/
this.ctx = null;
this.targetWidth = 1280;
this.targetHeight = 720;
this.previewScale = 2.0;
this.previewWidth = this.targetWidth * this.previewScale;
this.previewHeight = this.targetHeight * this.previewScale;
/**
* @type Image
*/
this.godotLogo = null;
this.includeGodotLogo = true;
// State parameters.
this.imageDragged = false;
this.imageDraggedLast = [0, 0];
// Dynamic parameters.
/**
* @type Image
*/
this.coverImage = null;
this.titleText = "";
this.superText = "";
this.generatedFilename = "";
this.clearColor = "";
this.coverImageScale = 1.0;
this.coverImageOffset = [0, 0];
// Helpers.
this.numberFormatter = new Intl.NumberFormat("en", {
"minimumFractionDigits": 3,
"useGrouping": false
});
this._debounceTimerId = -1;
}
init() {
this.targetCanvas = document.getElementById("render-target");
this.targetCanvas.width = this.targetWidth;
this.targetCanvas.height = this.targetHeight;
this.previewCanvas = document.getElementById("generator");
this.previewCanvas.width = this.previewWidth;
this.previewCanvas.height = this.previewHeight;
this.ctx = this.previewCanvas.getContext("2d");
// Fonts may take a moment to load, make sure to update preview when
// they are done loading.
document.fonts.onloadingdone = () => {
this.render();
}
// Load the Godot logo.
this._loadImage("assets/godot-logo.svg", (image) => {
this.godotLogo = image;
this.render();
});
// Connect to the toolbar panel to react on changes.
this._initForm();
// Connect to mouse events to react to changes.
this._initEvents();
// Update the download filename for the first time.
this._updateFilename();
// Do the first render.
this.render();
}
_initForm() {
const backgroundImage_selector = document.getElementById("background-image");
backgroundImage_selector.addEventListener("change", () => {
const selectedFiles = backgroundImage_selector.files;
if (selectedFiles.length === 0) {
this.coverImage = null;
this._fitCoverImage();
this.render();
} else {
const imageFile = selectedFiles[0];
createImageBitmap(imageFile)
.then((res) => {
this.coverImage = res;
this._fitCoverImage();
this.render();
})
.catch((err) => {
this.coverImage = null;
this._fitCoverImage();
this.render();
});
}
});
const backgroundImage_fit = document.getElementById("background-image-fit");
backgroundImage_fit.addEventListener("click", () => {
this._fitCoverImage();
this.render();
});
const titleText_input = document.getElementById("title-text");
titleText_input.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const superText_input = document.getElementById("super-text");
superText_input.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const includeGodotLogo_input = document.getElementById("include-godot-logo");
includeGodotLogo_input.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const clearColor_input = document.getElementById("clear-color");
clearColor_input.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const backgroundImage_scale = document.getElementById("background-image-scale");
backgroundImage_scale.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const backgroundImage_scaleReset = document.getElementById("background-image-scale-reset");
backgroundImage_scaleReset.addEventListener("click", () => {
this._setCoverImageScale(1.0);
});
const backgroundImage_offsetX = document.getElementById("background-image-offset-x");
backgroundImage_offsetX.addEventListener("input", () => {
this._debounceUpdateAndRender();
});
const backgroundImage_offsetY = document.getElementById("background-image-offset-y");
backgroundImage_offsetY.addEventListener("input", () => {
this._updateCoverImageOffset();
});
const backgroundImage_offsetReset = document.getElementById("background-image-offset-reset");
backgroundImage_offsetReset.addEventListener("click", () => {
this._setCoverImageOffset(0, 0);
});
const downloadImage_button = document.getElementById("download-image");
downloadImage_button.addEventListener("click", () => {
this._saveRender();
});
// Event listeners for preset supertext buttons
document.getElementById("preset-super-1").addEventListener("click", () => {
this._setSuperText("Progress report");
});
document.getElementById("preset-super-2").addEventListener("click", () => {
this._setSuperText("Dev snapshot");
});
document.getElementById("preset-super-3").addEventListener("click", () => {
this._setSuperText("Maintenance release");
});
document.getElementById("preset-super-4").addEventListener("click", () => {
this._setSuperText("Release candidate");
});
}
_initEvents() {
// Dragging over canvas to reposition the image.
document.addEventListener("mousedown", (event) => {
if (event.target !== this.previewCanvas) {
return;
}
this.imageDragged = true;
this.imageDraggedLast = [ event.clientX, event.clientY ];
event.preventDefault();
});
document.addEventListener("mouseup", () => {
if (this.imageDragged) {
this.imageDragged = false;
this.imageDraggedLast = [0, 0];
}
});
document.addEventListener("mousemove", (event) => {
if (!this.imageDragged) {
return;
}
const scaleFactor = this._getPreviewPageScale() / this.coverImageScale;
const nextOffsetX = this.coverImageOffset[0] + (event.clientX - this.imageDraggedLast[0]) * scaleFactor;
const nextOffsetY = this.coverImageOffset[1] + (event.clientY - this.imageDraggedLast[1]) * scaleFactor;
this.imageDraggedLast = [ event.clientX, event.clientY ];
this._setCoverImageOffset(nextOffsetX, nextOffsetY);
});
// Scrolling over canvas to scale/zoom the image.
this.previewCanvas.addEventListener("wheel", (event) => {
event.preventDefault();
let scaleFactor = this._getPreviewPageScale() / this.coverImageScale;
const centerX = (event.clientX - this.previewCanvas.offsetLeft) * scaleFactor - this.coverImageOffset[0];
const centerY = (event.clientY - this.previewCanvas.offsetTop) * scaleFactor - this.coverImageOffset[1];
const oldOffsetX = (this.coverImageOffset[0] + centerX) / scaleFactor;
const oldOffsetY = (this.coverImageOffset[1] + centerY) / scaleFactor;
this._setCoverImageScale(this.coverImageScale / (1.0 + event.deltaY / 1000));
scaleFactor = this._getPreviewPageScale() / this.coverImageScale;
this._setCoverImageOffset(oldOffsetX * scaleFactor - centerX, oldOffsetY * scaleFactor - centerY);
});
}
_updateFilename() {
this._generateFilename();
const filenameLabel = document.getElementById("download-filename");
filenameLabel.textContent = this.generatedFilename;
}
_setCoverImageScale(value) {
const backgroundImage_scale = document.getElementById("background-image-scale");
backgroundImage_scale.value = value;
this._updateCoverImageScale();
}
_updateCoverImageScale() {
const backgroundImage_scale = document.getElementById("background-image-scale");
const backgroundImage_scaleText = document.getElementById("background-image-scale-value");
this.coverImageScale = parseFloat(backgroundImage_scale.value);
backgroundImage_scaleText.textContent = this.numberFormatter.format(this.coverImageScale);
this.render();
}
_setCoverImageOffset(valueX, valueY) {
const backgroundImage_offsetX = document.getElementById("background-image-offset-x");
const backgroundImage_offsetY = document.getElementById("background-image-offset-y");
backgroundImage_offsetX.value = this.numberFormatter.format(valueX);
backgroundImage_offsetY.value = this.numberFormatter.format(valueY);
this._updateCoverImageOffset();
}
_updateCoverImageOffset() {
const backgroundImage_offsetX = document.getElementById("background-image-offset-x");
const backgroundImage_offsetY = document.getElementById("background-image-offset-y");
this.coverImageOffset[0] = parseFloat(backgroundImage_offsetX.value);
this.coverImageOffset[1] = parseFloat(backgroundImage_offsetY.value);
this.render();
}
_fitCoverImage() {
if (!this.coverImage) {
this._setCoverImageScale(1.0);
this._setCoverImageOffset(0, 0);
return;
}
const fittingScale = this.previewWidth / this.coverImage.width;
this._setCoverImageScale(fittingScale);
this._setCoverImageOffset(0, (this.previewHeight / fittingScale - this.coverImage.height) / 2);
}
_getPreviewPageScale() {
return this.previewWidth / this.previewCanvas.offsetWidth;
}
render() {
window.requestAnimationFrame(this._renderRoutine.bind(this));
}
_renderRoutine() {
if (!this.previewCanvas || !this.ctx) {
return;
}
// Clear the canvas.
this.ctx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
// Reset rendering styles.
this.ctx.fillStyle = "black";
this.ctx.shadowBlur = 0;
this.ctx.shadowColor = "rgba(0, 0, 0, 0)";
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.font = "10px sans-serif";
this.ctx.letterSpacing = "0px";
// Render the clear color.
this.ctx.fillStyle = this.clearColor;
this.ctx.fillRect(0, 0, this.previewWidth, this.previewHeight);
// Render the cover image.
if (this.coverImage) {
this.ctx.scale(this.coverImageScale, this.coverImageScale);
this.ctx.drawImage(this.coverImage, 0, 0, this.coverImage.width, this.coverImage.height, this.coverImageOffset[0], this.coverImageOffset[1], this.coverImage.width, this.coverImage.height);
this.ctx.resetTransform();
}
// Render the overlay as a gradient from top-right to bottom-left.
const overlayGradient = this.ctx.createLinearGradient(this.previewWidth, 0, 0, this.previewHeight);
overlayGradient.addColorStop(0, "rgba(32, 79, 159, 0.1)");
overlayGradient.addColorStop(0.85, "rgba(14, 13, 30, 0.4)");
this.ctx.fillStyle = overlayGradient;
this.ctx.fillRect(0, 0, this.previewWidth, this.previewHeight);
// Render decorations.
const relativeUnit = (this.previewWidth - 6) / 100.0;
const paddingSize = 4 * relativeUnit;
// Render the title.
const titleSize = 8 * relativeUnit;
const titleOffset = 2 * relativeUnit + paddingSize + 0.2 * titleSize;
this.ctx.font = `bold ${titleSize}px 'JetBrains Mono', monospace`;
this.ctx.letterSpacing = "0px";
this.ctx.fillStyle = "white";
this.ctx.fillText(this.titleText, paddingSize, this.previewHeight - titleOffset);
// Render the super text.
const supertextSize = 3.5 * relativeUnit;
const supertextOffset = 3 * relativeUnit + titleSize + titleOffset + 0.06 * supertextSize;
this.ctx.font = `bold ${supertextSize}px 'JetBrains Mono', monospace`;
this.ctx.letterSpacing = `${1.4 * relativeUnit}px`;
this.ctx.fillStyle = "white";
this.ctx.fillText(this.superText.toUpperCase(), paddingSize, this.previewHeight - supertextOffset);
// Render break line.
const breaklineWidth = 8 * relativeUnit;
const breaklineHeight = 0.6 * relativeUnit;
const breaklineOffset = 3 * relativeUnit + supertextSize + supertextOffset + 0.2 * breaklineHeight;
this.ctx.fillStyle = "white";
this.ctx.fillRect(paddingSize, this.previewHeight - breaklineOffset - breaklineHeight, breaklineWidth, breaklineHeight);
// Render the Godot logo.
if (this.godotLogo && this.includeGodotLogo) {
const logoWidth = 0.36 * this.previewWidth;
const logoHeight = this.godotLogo.height * (logoWidth / this.godotLogo.width);
this.ctx.shadowBlur = 140;
this.ctx.shadowColor = "rgb(0 0 0 / 0.4)";
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.drawImage(this.godotLogo, this.previewWidth - paddingSize - logoWidth, paddingSize, logoWidth, logoHeight);
}
}
_debounceUpdateAndRender() {
if (this._debounceTimerId >= 0) {
clearTimeout(this._debounceTimerId);
}
this._debounceTimerId = setTimeout(() => {
this._debounceTimerId = -1;
this._updateAndRender();
}, PreviewGenerator.DEBOUNCE_TIME_MS);
}
_updateAndRender() {
const titleText_input = document.getElementById("title-text");
this.titleText = titleText_input.value;
const superText_input = document.getElementById("super-text");
this.superText = superText_input.value;
this._updateFilename();
const includeGodotLogo_input = document.getElementById("include-godot-logo");
this.includeGodotLogo = includeGodotLogo_input.checked;
const clearColor_input = document.getElementById("clear-color");
this.clearColor = clearColor_input.value;
this._updateCoverImageScale();
this._updateCoverImageOffset();
this.render();
}
/**
*
* @param {String} imagePath
* @param {CallableFunction} callback
*/
_loadImage(imagePath, callback) {
const image = new Image();
image.onload = () => {
callback(image);
};
image.src = imagePath;
}
_preprocessFilenamePart(inString) {
const cleanupRegex = /([^\p{L}0-9]+)/gui;
const diacriticRegex = /\p{Diacritic}/gui;
let outString = inString
.trim()
.toLowerCase()
.normalize("NFD")
.replace(diacriticRegex, "")
.replace(cleanupRegex, "-");
return outString;
}
_generateFilename() {
// Normalize and sanitize supertext and title.
let superPrefix = this._preprocessFilenamePart(this.superText);
let titlePostfix = this._preprocessFilenamePart(this.titleText);
if (superPrefix === "" && titlePostfix === "") {
this.generatedFilename = "image.jpg";
return;
}
this.generatedFilename = superPrefix;
if (titlePostfix !== "") {
if (this.generatedFilename !== "") {
this.generatedFilename += "-";
}
this.generatedFilename += titlePostfix;
}
this.generatedFilename += ".jpg";
}
_saveRender() {
if (!this.previewCanvas || !this.targetCanvas) {
return;
}
const targetContext = this.targetCanvas.getContext("2d");
targetContext.clearRect(0, 0, this.targetCanvas.width, this.targetCanvas.height);
targetContext.drawImage(this.previewCanvas, 0, 0, this.targetWidth, this.targetHeight);
const imageData = this.targetCanvas.toDataURL("image/jpeg", 0.95);
const fakeAnchor = document.createElement("A");
fakeAnchor.setAttribute("download", this.generatedFilename);
fakeAnchor.setAttribute("href", imageData);
fakeAnchor.click();
}
_setSuperText(text) {
const superText_input = document.getElementById("super-text");
superText_input.value = text;
this._debounceUpdateAndRender();
}
}