Encode the full Glitztype piece in a local share URL
This commit is contained in:
1 parent
f2b5e93e8e
commit
9065eebfcb
1 file changed
+515
-204
+515
-204
@@ -1,35 +1,63 @@
|
||||
// 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;
|
||||
|
||||
let currentText = "Glitztype";
|
||||
let currentStyle = "rainbow";
|
||||
let currentFont = "Arial";
|
||||
let currentBend = 0;
|
||||
let currentColors = {
|
||||
rainbow: ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff"],
|
||||
chrome: "#cccccc",
|
||||
neon: "#00ffff",
|
||||
arch: "#ff6600",
|
||||
gradient: "#ff00ff",
|
||||
shadow: "#000000",
|
||||
outline: "#ff0000",
|
||||
italic: "#0066cc",
|
||||
bold: "#333333",
|
||||
wave: "#9932cc",
|
||||
stripe: "#ff4500",
|
||||
glow: "#ffd700"
|
||||
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"
|
||||
}
|
||||
};
|
||||
|
||||
// Sample styles mimicking WordArt - expanded to a dozen
|
||||
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 gradient = ctx.createLinearGradient(0, 0, 300, 0);
|
||||
const colors = currentColors.rainbow;
|
||||
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]);
|
||||
}
|
||||
@@ -39,40 +67,59 @@
|
||||
},
|
||||
chrome: {
|
||||
name: "Chrome",
|
||||
draw: function(ctx, text, x, y, fontSize) {
|
||||
ctx.fillStyle = currentColors.chrome;
|
||||
ctx.strokeStyle = "#333333";
|
||||
ctx.lineWidth = 2;
|
||||
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) {
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.strokeStyle = currentColors.neon;
|
||||
ctx.lineWidth = 3;
|
||||
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 = currentColors.arch;
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
const chars = text.split('');
|
||||
const arcRadius = 50;
|
||||
const angleStep = Math.PI / (chars.length + 1);
|
||||
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 = angleStep * (i + 1);
|
||||
const angle = start - angleStep * i;
|
||||
const charX = x + Math.cos(angle) * arcRadius;
|
||||
const charY = y - Math.sin(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.rotate(Math.PI / 2 - angle);
|
||||
ctx.fillText(chars[i], 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -80,60 +127,68 @@
|
||||
},
|
||||
gradient: {
|
||||
name: "Vertical Gradient",
|
||||
draw: function(ctx, text, x, y, fontSize) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 100);
|
||||
gradient.addColorStop(0, currentColors.gradient);
|
||||
gradient.addColorStop(1, "#800080");
|
||||
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) {
|
||||
ctx.fillStyle = "#aaaaaa";
|
||||
ctx.fillText(text, x + 3, y + 3);
|
||||
ctx.fillStyle = currentColors.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.fillStyle = "#ffffff";
|
||||
ctx.strokeStyle = currentColors.outline;
|
||||
ctx.lineWidth = 5;
|
||||
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 = currentColors.italic;
|
||||
ctx.font = `bold italic ${fontSize}px Arial`;
|
||||
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 = currentColors.bold;
|
||||
ctx.font = `900 ${fontSize}px Arial`;
|
||||
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 = currentColors.wave;
|
||||
ctx.font = `bold ${fontSize}px Arial`;
|
||||
const chars = text.split('');
|
||||
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.5) * 15;
|
||||
const waveOffset = Math.sin(i * 0.7 + currentBend / 30) * (fontSize * 0.28);
|
||||
ctx.save();
|
||||
ctx.translate(x - (text.length * fontSize/4) + (i * fontSize/2), y + waveOffset);
|
||||
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();
|
||||
}
|
||||
@@ -141,56 +196,242 @@
|
||||
},
|
||||
stripe: {
|
||||
name: "Striped",
|
||||
draw: function(ctx, text, x, y, fontSize) {
|
||||
ctx.fillStyle = currentColors.stripe;
|
||||
draw: function (ctx, text, x, y, fontSize) {
|
||||
ctx.fillStyle = colorOf("stripe", "#ff4500");
|
||||
ctx.fillText(text, x, y);
|
||||
|
||||
// Add stripes
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
for (let i = -textWidth; i < textWidth; i += 10) {
|
||||
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 - 30);
|
||||
ctx.lineTo(x + i + 30, y + 30);
|
||||
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) {
|
||||
ctx.shadowColor = currentColors.glow;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
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="${currentText}">
|
||||
<button id="downloadBtn">Download PNG</button>
|
||||
<button id="shareBtn">Copy Share URL</button>
|
||||
<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="400" height="200"></canvas>
|
||||
<canvas id="previewCanvas" width="640" height="280"></canvas>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Font:
|
||||
<select id="fontSelect">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
${fontOptions()}
|
||||
</select>
|
||||
</label>
|
||||
<label>Bend:
|
||||
@@ -200,175 +441,245 @@
|
||||
<input type="color" id="colorPicker" value="#ff0000">
|
||||
</label>
|
||||
</div>
|
||||
<div class="note" style="text-align: center; margin-top: 1rem;">
|
||||
New style pack drops every Friday
|
||||
</div>
|
||||
<p class="note friday-note">New style pack drops every Friday</p>
|
||||
`;
|
||||
|
||||
renderStyleGrid();
|
||||
setupEventListeners();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
// Render style grid
|
||||
// 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 = '';
|
||||
Object.keys(styles).forEach(styleKey => {
|
||||
grid.innerHTML = "";
|
||||
STYLE_KEYS.forEach(function (styleKey) {
|
||||
const style = styles[styleKey];
|
||||
const el = document.createElement("div");
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "style-option";
|
||||
el.dataset.style = styleKey;
|
||||
el.textContent = style.name;
|
||||
if (styleKey === currentStyle) {
|
||||
el.classList.add("selected");
|
||||
}
|
||||
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 = e.target.value || " ";
|
||||
document.getElementById("textInput").addEventListener("input", function (e) {
|
||||
currentText = clampText(e.target.value) || " ";
|
||||
renderStyleGrid();
|
||||
renderPreview();
|
||||
writeUrl();
|
||||
});
|
||||
|
||||
document.getElementById("fontSelect").addEventListener("change", function(e) {
|
||||
currentFont = e.target.value;
|
||||
document.getElementById("fontSelect").addEventListener("change", function (e) {
|
||||
currentFont = clampFont(e.target.value);
|
||||
renderStyleGrid();
|
||||
renderPreview();
|
||||
writeUrl();
|
||||
});
|
||||
|
||||
document.getElementById("bendSlider").addEventListener("input", function(e) {
|
||||
currentBend = parseInt(e.target.value);
|
||||
document.getElementById("bendSlider").addEventListener("input", function (e) {
|
||||
currentBend = clampBend(e.target.value);
|
||||
renderStyleGrid();
|
||||
renderPreview();
|
||||
writeUrl();
|
||||
});
|
||||
|
||||
document.getElementById("colorPicker").addEventListener("input", function(e) {
|
||||
// Update the current style's color
|
||||
currentColors[currentStyle] = e.target.value;
|
||||
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("shareBtn").addEventListener("click", copyShareURL);
|
||||
document.getElementById("shareLink").addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
copyShareURL();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".style-option").forEach(el => {
|
||||
el.addEventListener("click", function() {
|
||||
document.querySelectorAll(".style-option").forEach(e => e.classList.remove("selected"));
|
||||
this.classList.add("selected");
|
||||
currentStyle = this.dataset.style;
|
||||
// Update color picker to match current style's color
|
||||
document.getElementById("colorPicker").value = currentColors[currentStyle];
|
||||
renderPreview();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// Render preview
|
||||
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 ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = `bold 48px ${currentFont}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
const styleFunc = styles[currentStyle] ? styles[currentStyle].draw : styles.rainbow.draw;
|
||||
|
||||
// Apply bend transformation if needed
|
||||
if (currentBend !== 0) {
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate(currentBend * Math.PI / 180);
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
||||
styleFunc(ctx, currentText, canvas.width / 2, canvas.height / 2, 48);
|
||||
ctx.restore();
|
||||
} else {
|
||||
styleFunc(ctx, currentText, canvas.width / 2, canvas.height / 2, 48);
|
||||
}
|
||||
const text = currentText || " ";
|
||||
const fontSize = text.length > 16 ? 56 : text.length > 10 ? 72 : 84;
|
||||
paintStyle(canvas, currentStyle, text, fontSize);
|
||||
}
|
||||
|
||||
// Download PNG
|
||||
// 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 = `${currentText || "wordart"}-${currentStyle}.png`;
|
||||
link.download = slug + "-" + currentStyle + ".png";
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
}
|
||||
|
||||
// Copy share URL
|
||||
|
||||
// Copy a URL that encodes the full piece locally. No server round-trip.
|
||||
function copyShareURL() {
|
||||
// Create state object to encode
|
||||
const state = {
|
||||
text: currentText,
|
||||
style: currentStyle,
|
||||
font: currentFont,
|
||||
bend: currentBend,
|
||||
colors: currentColors
|
||||
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);
|
||||
};
|
||||
|
||||
// Use Time.save to get a shareable link
|
||||
Time.save(state).then(function(link) {
|
||||
if (link) {
|
||||
const fullUrl = new URL(window.location);
|
||||
fullUrl.searchParams.set('s', link);
|
||||
navigator.clipboard.writeText(fullUrl.toString()).then(function() {
|
||||
alert("Share URL copied to clipboard!");
|
||||
}).catch(function() {
|
||||
// Fallback if clipboard fails
|
||||
prompt("Copy this share URL:", fullUrl.toString());
|
||||
});
|
||||
} else {
|
||||
// Fallback for when backend is unavailable
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
const params = new URLSearchParams({
|
||||
text: currentText,
|
||||
style: currentStyle,
|
||||
font: currentFont,
|
||||
bend: currentBend
|
||||
});
|
||||
const fallbackUrl = `${baseUrl}?${params}`;
|
||||
navigator.clipboard.writeText(fallbackUrl).then(function() {
|
||||
alert("Share URL copied to clipboard! (local encoding)");
|
||||
}).catch(function() {
|
||||
prompt("Copy this share URL:", fallbackUrl);
|
||||
});
|
||||
}
|
||||
copyText(url).then(function () {
|
||||
flash("Copied");
|
||||
}).catch(function () {
|
||||
flash("Copy the link");
|
||||
window.prompt("Share link", url);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
window.addEventListener("DOMContentLoaded", function() {
|
||||
function boot() {
|
||||
createUI();
|
||||
|
||||
// Check for shared state
|
||||
Time.load().then(function(data) {
|
||||
if (data) {
|
||||
currentText = data.text || currentText;
|
||||
currentStyle = data.style || currentStyle;
|
||||
currentFont = data.font || currentFont;
|
||||
currentBend = data.bend || currentBend;
|
||||
if (data.colors) {
|
||||
currentColors = {...currentColors, ...data.colors};
|
||||
}
|
||||
document.getElementById("textInput").value = currentText;
|
||||
document.getElementById("fontSelect").value = currentFont;
|
||||
document.getElementById("bendSlider").value = currentBend;
|
||||
// Update color picker to match current style's color
|
||||
document.getElementById("colorPicker").value = currentColors[currentStyle];
|
||||
renderStyleGrid();
|
||||
renderPreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
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); }
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user