686 lines
22 KiB
JavaScript
686 lines
22 KiB
JavaScript
// Glitztype App Logic
|
|
// The preloaded sample and a typed word share one path: state goes through
|
|
// renderPreview(), which draws on canvas. A share URL encodes that full state
|
|
// locally (base64url in ?s=) so a friend opens the exact same piece. Time.save
|
|
// is only an extra; the copied link never depends on a server id.
|
|
|
|
(function () {
|
|
const trySection = document.getElementById("try");
|
|
if (!trySection) return;
|
|
|
|
const SAMPLE = {
|
|
text: "Nadia",
|
|
style: "rainbow",
|
|
font: "Impact",
|
|
bend: 8,
|
|
colors: {
|
|
rainbow: ["#ff0000", "#ff8800", "#ffff00", "#00cc44", "#0088ff", "#cc00ff"],
|
|
chrome: "#d8d8e0",
|
|
neon: "#39ff14",
|
|
arch: "#ff6600",
|
|
gradient: "#ff2bd6",
|
|
shadow: "#1a1a2e",
|
|
outline: "#ff1744",
|
|
italic: "#0066cc",
|
|
bold: "#222222",
|
|
wave: "#9932cc",
|
|
stripe: "#ff4500",
|
|
glow: "#ffd700"
|
|
}
|
|
};
|
|
|
|
const FONTS = [
|
|
"Arial",
|
|
"Courier New",
|
|
"Georgia",
|
|
"Impact",
|
|
"Verdana",
|
|
"Comic Sans MS",
|
|
"Times New Roman"
|
|
];
|
|
|
|
const STYLE_KEYS = [
|
|
"rainbow", "chrome", "neon", "arch", "gradient", "shadow",
|
|
"outline", "italic", "bold", "wave", "stripe", "glow"
|
|
];
|
|
|
|
let currentText = SAMPLE.text;
|
|
let currentStyle = SAMPLE.style;
|
|
let currentFont = SAMPLE.font;
|
|
let currentBend = SAMPLE.bend;
|
|
let currentColors = cloneColors(SAMPLE.colors);
|
|
|
|
// Sample styles mimicking WordArt - a dozen clean-room treatments
|
|
const styles = {
|
|
rainbow: {
|
|
name: "Rainbow Gradient",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const width = Math.max(ctx.measureText(text).width, fontSize);
|
|
const gradient = ctx.createLinearGradient(x - width / 2, y, x + width / 2, y);
|
|
const colors = Array.isArray(currentColors.rainbow) ? currentColors.rainbow : SAMPLE.colors.rainbow;
|
|
for (let i = 0; i < colors.length; i++) {
|
|
gradient.addColorStop(i / (colors.length - 1), colors[i]);
|
|
}
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
chrome: {
|
|
name: "Chrome",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const width = Math.max(ctx.measureText(text).width, fontSize);
|
|
const gradient = ctx.createLinearGradient(x - width / 2, y - fontSize, x + width / 2, y + fontSize / 2);
|
|
const base = colorOf("chrome", "#c0c0c0");
|
|
gradient.addColorStop(0, "#ffffff");
|
|
gradient.addColorStop(0.35, base);
|
|
gradient.addColorStop(0.55, "#6e6e78");
|
|
gradient.addColorStop(0.75, "#f4f4f8");
|
|
gradient.addColorStop(1, "#8a8a96");
|
|
ctx.strokeStyle = "#2a2a32";
|
|
ctx.lineWidth = Math.max(2, fontSize / 18);
|
|
ctx.strokeText(text, x, y);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillText(text, x, y);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.7)";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeText(text, x, y - 1);
|
|
}
|
|
},
|
|
neon: {
|
|
name: "Neon Outline",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const neon = colorOf("neon", "#00ffff");
|
|
ctx.shadowColor = neon;
|
|
ctx.shadowBlur = fontSize / 3;
|
|
ctx.strokeStyle = neon;
|
|
ctx.lineWidth = Math.max(3, fontSize / 12);
|
|
ctx.lineJoin = "round";
|
|
ctx.strokeText(text, x, y);
|
|
ctx.shadowBlur = fontSize / 6;
|
|
ctx.strokeText(text, x, y);
|
|
ctx.fillStyle = "#050510";
|
|
ctx.shadowBlur = 0;
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
arch: {
|
|
name: "Arch",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.fillStyle = colorOf("arch", "#ff6600");
|
|
ctx.font = `bold ${fontSize}px ${currentFont}`;
|
|
const chars = text.split("");
|
|
const arcRadius = Math.max(fontSize * 1.6, chars.length * fontSize * 0.42);
|
|
const spread = Math.min(Math.PI * 0.85, Math.max(0.4, chars.length * 0.28));
|
|
const start = Math.PI / 2 + spread / 2;
|
|
const angleStep = chars.length > 1 ? spread / (chars.length - 1) : 0;
|
|
for (let i = 0; i < chars.length; i++) {
|
|
const angle = start - angleStep * i;
|
|
const charX = x + Math.cos(angle) * arcRadius;
|
|
const charY = y + fontSize * 0.35 - Math.sin(angle) * arcRadius + arcRadius * 0.35;
|
|
ctx.save();
|
|
ctx.translate(charX, charY);
|
|
ctx.rotate(Math.PI / 2 - angle);
|
|
ctx.fillText(chars[i], 0, 0);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
},
|
|
gradient: {
|
|
name: "Vertical Gradient",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const gradient = ctx.createLinearGradient(x, y - fontSize, x, y + fontSize * 0.4);
|
|
gradient.addColorStop(0, colorOf("gradient", "#ff00ff"));
|
|
gradient.addColorStop(1, "#4b0082");
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
shadow: {
|
|
name: "Drop Shadow",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const offset = Math.max(3, fontSize / 12);
|
|
ctx.fillStyle = "rgba(0,0,0,0.35)";
|
|
ctx.fillText(text, x + offset, y + offset);
|
|
ctx.fillStyle = colorOf("shadow", "#111111");
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
outline: {
|
|
name: "Thick Outline",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.lineJoin = "round";
|
|
ctx.miterLimit = 2;
|
|
ctx.lineWidth = Math.max(5, fontSize / 7);
|
|
ctx.strokeStyle = colorOf("outline", "#ff0000");
|
|
ctx.strokeText(text, x, y);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
italic: {
|
|
name: "Italic Slant",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.fillStyle = colorOf("italic", "#0066cc");
|
|
ctx.font = `bold italic ${fontSize}px ${currentFont}`;
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
bold: {
|
|
name: "Extra Bold",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.fillStyle = colorOf("bold", "#333333");
|
|
ctx.font = `900 ${fontSize}px ${currentFont}`;
|
|
ctx.lineWidth = Math.max(1, fontSize / 28);
|
|
ctx.strokeStyle = colorOf("bold", "#333333");
|
|
ctx.strokeText(text, x, y);
|
|
ctx.fillText(text, x, y);
|
|
}
|
|
},
|
|
wave: {
|
|
name: "Wave",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.fillStyle = colorOf("wave", "#9932cc");
|
|
ctx.font = `bold ${fontSize}px ${currentFont}`;
|
|
const chars = text.split("");
|
|
const advance = fontSize * 0.62;
|
|
const total = Math.max(advance, (chars.length - 1) * advance);
|
|
for (let i = 0; i < chars.length; i++) {
|
|
const waveOffset = Math.sin(i * 0.7 + currentBend / 30) * (fontSize * 0.28);
|
|
ctx.save();
|
|
ctx.translate(x - total / 2 + i * advance, y + waveOffset);
|
|
ctx.rotate(Math.cos(i * 0.7) * 0.18);
|
|
ctx.fillText(chars[i], 0, 0);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
},
|
|
stripe: {
|
|
name: "Striped",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
ctx.fillStyle = colorOf("stripe", "#ff4500");
|
|
ctx.fillText(text, x, y);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
// Clip stripes to the glyphs so the PNG stays a sticker, not a box.
|
|
ctx.fillText(text, x, y);
|
|
// The second fill is only to establish the clip path via destination-in below.
|
|
const textWidth = Math.max(ctx.measureText(text).width, fontSize);
|
|
ctx.globalCompositeOperation = "source-atop";
|
|
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
|
ctx.lineWidth = Math.max(2, fontSize / 18);
|
|
for (let i = -textWidth; i < textWidth; i += Math.max(8, fontSize / 5)) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + i, y - fontSize);
|
|
ctx.lineTo(x + i + fontSize * 0.6, y + fontSize * 0.6);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
},
|
|
glow: {
|
|
name: "Glow",
|
|
draw: function (ctx, text, x, y, fontSize) {
|
|
const glow = colorOf("glow", "#ffd700");
|
|
ctx.shadowColor = glow;
|
|
ctx.shadowBlur = fontSize / 2;
|
|
ctx.fillStyle = glow;
|
|
ctx.fillText(text, x, y);
|
|
ctx.shadowBlur = fontSize / 5;
|
|
ctx.fillStyle = "#fff8dc";
|
|
ctx.fillText(text, x, y);
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
}
|
|
};
|
|
|
|
function cloneColors(src) {
|
|
const out = {};
|
|
STYLE_KEYS.forEach(function (key) {
|
|
const value = src && src[key] != null ? src[key] : SAMPLE.colors[key];
|
|
out[key] = Array.isArray(value) ? value.slice() : value;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function colorOf(key, fallback) {
|
|
const value = currentColors[key];
|
|
if (typeof value === "string" && value) return value;
|
|
return fallback;
|
|
}
|
|
|
|
function hexColor(value, fallback) {
|
|
const s = String(value || "").trim();
|
|
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
|
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
|
|
return ("#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function pickerValue() {
|
|
const value = currentColors[currentStyle];
|
|
if (Array.isArray(value) && value.length) return hexColor(value[0], "#ff0000");
|
|
return hexColor(value, "#ff0000");
|
|
}
|
|
|
|
function clampText(value) {
|
|
const text = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
|
|
return text.slice(0, 42);
|
|
}
|
|
|
|
function clampBend(value) {
|
|
const n = parseInt(value, 10);
|
|
if (!isFinite(n)) return 0;
|
|
return Math.max(-30, Math.min(30, n));
|
|
}
|
|
|
|
function clampFont(value) {
|
|
return FONTS.indexOf(value) >= 0 ? value : "Arial";
|
|
}
|
|
|
|
function clampStyle(value) {
|
|
return styles[value] ? value : "rainbow";
|
|
}
|
|
|
|
// Full piece: text, style, font, bend, and every style color. This is what
|
|
// the share URL carries, and what renderPreview reads.
|
|
function pieceState() {
|
|
return {
|
|
text: currentText,
|
|
style: currentStyle,
|
|
font: currentFont,
|
|
bend: currentBend,
|
|
colors: cloneColors(currentColors)
|
|
};
|
|
}
|
|
|
|
function applyPiece(piece) {
|
|
const next = normalizePiece(piece);
|
|
if (!next) return false;
|
|
currentText = next.text;
|
|
currentStyle = next.style;
|
|
currentFont = next.font;
|
|
currentBend = next.bend;
|
|
currentColors = next.colors;
|
|
syncControls();
|
|
renderStyleGrid();
|
|
renderPreview();
|
|
return true;
|
|
}
|
|
|
|
function normalizePiece(piece) {
|
|
if (!piece || typeof piece !== "object") return null;
|
|
const text = clampText(piece.text);
|
|
if (!text) return null;
|
|
const colors = cloneColors(SAMPLE.colors);
|
|
const incoming = piece.colors && typeof piece.colors === "object" ? piece.colors : null;
|
|
if (incoming) {
|
|
STYLE_KEYS.forEach(function (key) {
|
|
if (key === "rainbow" && Array.isArray(incoming.rainbow) && incoming.rainbow.length >= 2) {
|
|
colors.rainbow = incoming.rainbow.slice(0, 8).map(function (c, i) {
|
|
return hexColor(c, SAMPLE.colors.rainbow[i] || "#ff0000");
|
|
});
|
|
return;
|
|
}
|
|
if (typeof incoming[key] === "string") {
|
|
colors[key] = hexColor(incoming[key], SAMPLE.colors[key]);
|
|
}
|
|
});
|
|
}
|
|
return {
|
|
text: text,
|
|
style: clampStyle(piece.style),
|
|
font: clampFont(piece.font),
|
|
bend: clampBend(piece.bend),
|
|
colors: colors
|
|
};
|
|
}
|
|
|
|
function encodeState(obj) {
|
|
const json = JSON.stringify({
|
|
text: obj.text,
|
|
style: obj.style,
|
|
font: obj.font,
|
|
bend: obj.bend,
|
|
colors: obj.colors
|
|
});
|
|
return btoa(unescape(encodeURIComponent(json)))
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/g, "");
|
|
}
|
|
|
|
function decodeState(token) {
|
|
try {
|
|
let b64 = String(token).replace(/-/g, "+").replace(/_/g, "/");
|
|
while (b64.length % 4) b64 += "=";
|
|
const json = decodeURIComponent(escape(atob(b64)));
|
|
return normalizePiece(JSON.parse(json));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function shareUrl() {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("s", encodeState(pieceState()));
|
|
url.hash = "";
|
|
return url.toString();
|
|
}
|
|
|
|
function writeUrl() {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("s", encodeState(pieceState()));
|
|
url.hash = "";
|
|
window.history.replaceState({}, "", url);
|
|
} catch (e) { /* file:// or a locked history */ }
|
|
}
|
|
|
|
function readUrl() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const token = params.get("s");
|
|
if (!token) return null;
|
|
// A server link id is short and has no encoded payload. Ignore it so the
|
|
// page never pretends a partial or remote token restored the piece.
|
|
if (/^[a-z0-9]{6,32}$/.test(token) && token.indexOf("-") < 0 && token.indexOf("_") < 0) {
|
|
return null;
|
|
}
|
|
return decodeState(token);
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
return new Promise(function (resolve, reject) {
|
|
const input = document.createElement("textarea");
|
|
input.value = text;
|
|
input.setAttribute("readonly", "");
|
|
input.style.position = "fixed";
|
|
input.style.left = "-9999px";
|
|
document.body.appendChild(input);
|
|
input.select();
|
|
try {
|
|
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
document.body.removeChild(input);
|
|
});
|
|
}
|
|
|
|
function fontOptions() {
|
|
return FONTS.map(function (font) {
|
|
return `<option value="${font}">${font}</option>`;
|
|
}).join("");
|
|
}
|
|
|
|
// Create UI elements
|
|
function createUI() {
|
|
trySection.innerHTML = `
|
|
<div class="input-area">
|
|
<input type="text" id="textInput" placeholder="Type a name or word" value="" maxlength="42" autocomplete="off">
|
|
<button id="downloadBtn" type="button">Download PNG</button>
|
|
<a href="#" id="shareLink">Copy share link</a>
|
|
</div>
|
|
<div class="style-grid" id="styleGrid"></div>
|
|
<div class="preview-container">
|
|
<canvas id="previewCanvas" width="640" height="280"></canvas>
|
|
</div>
|
|
<div class="controls">
|
|
<label>Font:
|
|
<select id="fontSelect">
|
|
${fontOptions()}
|
|
</select>
|
|
</label>
|
|
<label>Bend:
|
|
<input type="range" id="bendSlider" min="-30" max="30" value="0">
|
|
</label>
|
|
<label>Color:
|
|
<input type="color" id="colorPicker" value="#ff0000">
|
|
</label>
|
|
</div>
|
|
<p class="note friday-note">New style pack drops every Friday</p>
|
|
`;
|
|
|
|
renderStyleGrid();
|
|
setupEventListeners();
|
|
}
|
|
|
|
// Render style grid. Each tile is a live canvas of the current word, drawn
|
|
// by the same style functions as the main preview — not a canned thumbnail.
|
|
function renderStyleGrid() {
|
|
const grid = document.getElementById("styleGrid");
|
|
if (!grid) return;
|
|
|
|
grid.innerHTML = "";
|
|
STYLE_KEYS.forEach(function (styleKey) {
|
|
const style = styles[styleKey];
|
|
const el = document.createElement("button");
|
|
el.type = "button";
|
|
el.className = "style-option";
|
|
el.dataset.style = styleKey;
|
|
el.setAttribute("aria-pressed", styleKey === currentStyle ? "true" : "false");
|
|
if (styleKey === currentStyle) el.classList.add("selected");
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 220;
|
|
canvas.height = 84;
|
|
canvas.className = "style-thumb";
|
|
canvas.setAttribute("aria-hidden", "true");
|
|
const label = document.createElement("span");
|
|
label.textContent = style.name;
|
|
el.appendChild(canvas);
|
|
el.appendChild(label);
|
|
grid.appendChild(el);
|
|
paintStyle(canvas, styleKey, currentText || " ", 28);
|
|
});
|
|
}
|
|
|
|
function paintStyle(canvas, styleKey, text, fontSize) {
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.font = `bold ${fontSize}px ${currentFont}`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const draw = styles[styleKey] ? styles[styleKey].draw : styles.rainbow.draw;
|
|
ctx.save();
|
|
if (currentBend !== 0 && styleKey !== "arch" && styleKey !== "wave") {
|
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
ctx.rotate(currentBend * Math.PI / 180);
|
|
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
|
}
|
|
draw(ctx, text, canvas.width / 2, canvas.height / 2, fontSize);
|
|
ctx.restore();
|
|
}
|
|
|
|
function syncControls() {
|
|
const input = document.getElementById("textInput");
|
|
const font = document.getElementById("fontSelect");
|
|
const bend = document.getElementById("bendSlider");
|
|
const color = document.getElementById("colorPicker");
|
|
if (input) input.value = currentText;
|
|
if (font) font.value = currentFont;
|
|
if (bend) bend.value = String(currentBend);
|
|
if (color) color.value = pickerValue();
|
|
}
|
|
|
|
// Setup event listeners
|
|
function setupEventListeners() {
|
|
document.getElementById("textInput").addEventListener("input", function (e) {
|
|
currentText = clampText(e.target.value) || " ";
|
|
renderStyleGrid();
|
|
renderPreview();
|
|
writeUrl();
|
|
});
|
|
|
|
document.getElementById("fontSelect").addEventListener("change", function (e) {
|
|
currentFont = clampFont(e.target.value);
|
|
renderStyleGrid();
|
|
renderPreview();
|
|
writeUrl();
|
|
});
|
|
|
|
document.getElementById("bendSlider").addEventListener("input", function (e) {
|
|
currentBend = clampBend(e.target.value);
|
|
renderStyleGrid();
|
|
renderPreview();
|
|
writeUrl();
|
|
});
|
|
|
|
document.getElementById("colorPicker").addEventListener("input", function (e) {
|
|
const picked = hexColor(e.target.value, "#ff0000");
|
|
if (currentStyle === "rainbow") {
|
|
currentColors.rainbow = rainbowFrom(picked);
|
|
} else {
|
|
currentColors[currentStyle] = picked;
|
|
}
|
|
renderStyleGrid();
|
|
renderPreview();
|
|
writeUrl();
|
|
});
|
|
|
|
document.getElementById("downloadBtn").addEventListener("click", downloadPNG);
|
|
document.getElementById("shareLink").addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
copyShareURL();
|
|
});
|
|
|
|
document.getElementById("styleGrid").addEventListener("click", function (e) {
|
|
const el = e.target.closest(".style-option");
|
|
if (!el) return;
|
|
document.querySelectorAll(".style-option").forEach(function (node) {
|
|
node.classList.remove("selected");
|
|
node.setAttribute("aria-pressed", "false");
|
|
});
|
|
el.classList.add("selected");
|
|
el.setAttribute("aria-pressed", "true");
|
|
currentStyle = clampStyle(el.dataset.style);
|
|
document.getElementById("colorPicker").value = pickerValue();
|
|
renderPreview();
|
|
writeUrl();
|
|
});
|
|
}
|
|
|
|
function rainbowFrom(hex) {
|
|
const base = hexColor(hex, "#ff0000");
|
|
const hsl = hexToHsl(base);
|
|
const stops = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
stops.push(hslToHex((hsl.h + i * 48) % 360, Math.max(70, hsl.s), Math.max(48, hsl.l)));
|
|
}
|
|
return stops;
|
|
}
|
|
|
|
function hexToHsl(hex) {
|
|
const n = parseInt(hex.slice(1), 16);
|
|
const r = ((n >> 16) & 255) / 255;
|
|
const g = ((n >> 8) & 255) / 255;
|
|
const b = (n & 255) / 255;
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
let h = 0;
|
|
const l = (max + min) / 2;
|
|
const d = max - min;
|
|
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
|
if (d !== 0) {
|
|
if (max === r) h = ((g - b) / d) % 6;
|
|
else if (max === g) h = (b - r) / d + 2;
|
|
else h = (r - g) / d + 4;
|
|
h *= 60;
|
|
if (h < 0) h += 360;
|
|
}
|
|
return { h: h, s: s * 100, l: l * 100 };
|
|
}
|
|
|
|
function hslToHex(h, s, l) {
|
|
s /= 100;
|
|
l /= 100;
|
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
const m = l - c / 2;
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
if (h < 60) { r = c; g = x; }
|
|
else if (h < 120) { r = x; g = c; }
|
|
else if (h < 180) { g = c; b = x; }
|
|
else if (h < 240) { g = x; b = c; }
|
|
else if (h < 300) { r = x; b = c; }
|
|
else { r = c; b = x; }
|
|
const to = function (v) {
|
|
return Math.round((v + m) * 255).toString(16).padStart(2, "0");
|
|
};
|
|
return "#" + to(r) + to(g) + to(b);
|
|
}
|
|
|
|
// Render preview. Sample and user input both land here.
|
|
function renderPreview() {
|
|
const canvas = document.getElementById("previewCanvas");
|
|
if (!canvas) return;
|
|
const text = currentText || " ";
|
|
const fontSize = text.length > 16 ? 56 : text.length > 10 ? 72 : 84;
|
|
paintStyle(canvas, currentStyle, text, fontSize);
|
|
}
|
|
|
|
// Download a transparent PNG of the same canvas the preview just drew.
|
|
function downloadPNG() {
|
|
const canvas = document.getElementById("previewCanvas");
|
|
if (!canvas) return;
|
|
renderPreview();
|
|
const slug = (clampText(currentText) || "wordart")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "") || "wordart";
|
|
const link = document.createElement("a");
|
|
link.download = slug + "-" + currentStyle + ".png";
|
|
link.href = canvas.toDataURL("image/png");
|
|
link.click();
|
|
}
|
|
|
|
// Copy a URL that encodes the full piece locally. No server round-trip.
|
|
function copyShareURL() {
|
|
const link = document.getElementById("shareLink");
|
|
const url = shareUrl();
|
|
writeUrl();
|
|
const flash = function (label) {
|
|
if (!link) return;
|
|
const previous = link.textContent;
|
|
link.textContent = label;
|
|
setTimeout(function () { link.textContent = previous; }, 1600);
|
|
};
|
|
copyText(url).then(function () {
|
|
flash("Copied");
|
|
}).catch(function () {
|
|
flash("Copy the link");
|
|
window.prompt("Share link", url);
|
|
});
|
|
}
|
|
|
|
function boot() {
|
|
createUI();
|
|
const fromUrl = readUrl();
|
|
if (fromUrl) {
|
|
applyPiece(fromUrl);
|
|
return;
|
|
}
|
|
// First load: the sample is real input through the same apply/render path
|
|
// a typed word and a shared URL use. Nothing is a canned picture.
|
|
applyPiece(SAMPLE);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", boot);
|
|
} else {
|
|
boot();
|
|
}
|
|
|
|
window.Glitztype = {
|
|
encodeState: function () { return encodeState(pieceState()); },
|
|
decodeState: decodeState,
|
|
piece: pieceState,
|
|
sample: function () { return normalizePiece(SAMPLE); }
|
|
};
|
|
})();
|