mirror of
https://github.com/godotengine/godot-website-cover-generator.git
synced 2025-12-31 01:49:34 +03:00
483 lines
17 KiB
JavaScript
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();
|
|
}
|
|
|
|
}
|