tilekit / src /main.ts
osolmaz's picture
osolmaz HF Staff
fix: canonicalize shared URL paths
e3726e8 verified
Raw
History Blame Contribute Delete
14.8 kB
import "./style.css";
import logoSvg from "../assets/hugging-face.svg?raw";
import { generateLayout, type LayoutOptions } from "./layout";
import { renderWallpaper, type WallpaperSymbol } from "./render";
import { decodeShareState, encodeShareState, type ShareState } from "./url-state";
interface Config extends LayoutOptions {
width: number;
height: number;
}
function element<T extends HTMLElement>(id: string): T {
const value = document.getElementById(id);
if (!value) throw new Error(`Missing element: ${id}`);
return value as T;
}
const preview = element<HTMLCanvasElement>("preview");
const canvasWrap = element<HTMLDivElement>("canvas-wrap");
const resolution = element<HTMLSelectElement>("resolution");
const widthInput = element<HTMLInputElement>("width");
const heightInput = element<HTMLInputElement>("height");
const logoModeButton = element<HTMLButtonElement>("logo-mode");
const emojiModeButton = element<HTMLButtonElement>("emoji-mode");
const emojiControls = element<HTMLDivElement>("emoji-controls");
const emojiPickerToggle = element<HTMLButtonElement>("emoji-picker-toggle");
const emojiPickerPanel = element<HTMLDivElement>("emoji-picker-panel");
const selectedEmojiElement = element<HTMLSpanElement>("selected-emoji");
const selectedEmojiName = element<HTMLElement>("selected-emoji-name");
const modeEmoji = element<HTMLSpanElement>("mode-emoji");
const densityInput = element<HTMLInputElement>("density");
const sizeInput = element<HTMLInputElement>("logo-size");
const sizeVariationInput = element<HTMLInputElement>("size-variation");
const spacingInput = element<HTMLInputElement>("spacing");
const rotationInput = element<HTMLInputElement>("rotation");
const seedInput = element<HTMLInputElement>("seed");
const exportButton = element<HTMLButtonElement>("export");
const shareButton = element<HTMLButtonElement>("share");
const exportStatus = element<HTMLParagraphElement>("export-status");
const renderStats = element<HTMLSpanElement>("render-stats");
let logo: CanvasImageSource | undefined;
let currentLayout: Float32Array<ArrayBufferLike> = new Float32Array();
let currentConfigKey = "";
let framePending = false;
let symbolMode: "logo" | "emoji" = "logo";
let selectedEmoji = "🤗";
let pickerLoading: Promise<void> | undefined;
function numberValue(input: HTMLInputElement, fallback: number): number {
return Number.isFinite(input.valueAsNumber) ? input.valueAsNumber : fallback;
}
function selectedSymbol(): WallpaperSymbol | undefined {
return symbolMode === "logo" ? logo : selectedEmoji;
}
function closeEmojiPicker(): void {
emojiPickerPanel.hidden = true;
emojiPickerToggle.setAttribute("aria-expanded", "false");
}
function setSymbolMode(mode: "logo" | "emoji"): void {
symbolMode = mode;
logoModeButton.setAttribute("aria-pressed", String(mode === "logo"));
emojiModeButton.setAttribute("aria-pressed", String(mode === "emoji"));
emojiControls.hidden = mode !== "emoji";
if (mode === "logo") closeEmojiPicker();
schedulePreview();
}
async function loadEmojiPicker(): Promise<void> {
if (pickerLoading) return pickerLoading;
pickerLoading = Promise.all([
import("emoji-picker-element/picker"),
import("emoji-picker-element-data/en/emojibase/data.json?url"),
]).then(([{ default: Picker }, { default: dataSource }]) => {
const picker = new Picker({ dataSource, locale: "en" });
picker.classList.add("light");
picker.addEventListener("emoji-click", (event) => {
const unicode = event.detail.unicode;
if (!unicode) return;
selectedEmoji = unicode;
selectedEmojiElement.textContent = unicode;
modeEmoji.textContent = unicode;
const emoji = event.detail.emoji as { name?: string; annotation?: string };
const name = event.detail.name || emoji.name || emoji.annotation || "Selected emoji";
selectedEmojiName.textContent = name.charAt(0).toUpperCase() + name.slice(1);
closeEmojiPicker();
schedulePreview();
});
emojiPickerPanel.replaceChildren(picker);
}).catch((error: unknown) => {
pickerLoading = undefined;
emojiPickerPanel.textContent = error instanceof Error ? `Could not load emoji: ${error.message}` : "Could not load emoji picker";
});
return pickerLoading;
}
function readConfig(): Config {
const width = Math.round(Math.min(7680, Math.max(640, numberValue(widthInput, 3840))));
const height = Math.round(Math.min(4320, Math.max(360, numberValue(heightInput, 2160))));
return {
width,
height,
aspect: width / height,
count: Math.round(numberValue(densityInput, 88)),
logoScale: numberValue(sizeInput, 1),
sizeVariation: numberValue(sizeVariationInput, 1),
spacing: numberValue(spacingInput, 1),
rotation: numberValue(rotationInput, 52),
seed: Math.round(numberValue(seedInput, 84237)) >>> 0,
};
}
function currentShareState(config = readConfig()): ShareState {
return {
width: config.width,
height: config.height,
symbol: symbolMode === "logo" ? "hf" : selectedEmoji,
density: config.count,
size: config.logoScale,
variation: config.sizeVariation,
spacing: config.spacing,
rotation: config.rotation,
seed: config.seed,
};
}
function canonicalUrl(config = readConfig()): URL {
const url = new URL(window.location.href);
if (url.pathname.endsWith("/index.html")) url.pathname = url.pathname.slice(0, -"index.html".length);
url.search = encodeShareState(currentShareState(config));
url.hash = "";
return url;
}
function syncUrl(config: Config): void {
window.history.replaceState(null, "", canonicalUrl(config));
}
function restoreFromUrl(): void {
const state = decodeShareState(window.location.search);
if (state.width !== undefined && state.height !== undefined) {
widthInput.value = String(state.width);
heightInput.value = String(state.height);
const preset = `${state.width}x${state.height}`;
resolution.value = [...resolution.options].some((option) => option.value === preset) ? preset : "custom";
}
if (state.symbol !== undefined) {
if (state.symbol === "hf") setSymbolMode("logo");
else {
selectedEmoji = state.symbol;
selectedEmojiElement.textContent = selectedEmoji;
modeEmoji.textContent = selectedEmoji;
selectedEmojiName.textContent = "Emoji from URL";
setSymbolMode("emoji");
}
}
if (state.density !== undefined) densityInput.value = String(state.density);
if (state.size !== undefined) sizeInput.value = String(state.size);
if (state.variation !== undefined) sizeVariationInput.value = String(state.variation);
if (state.spacing !== undefined) spacingInput.value = String(state.spacing);
if (state.rotation !== undefined) rotationInput.value = String(state.rotation);
if (state.seed !== undefined) seedInput.value = String(state.seed);
schedulePreview();
}
function configKey(config: Config): string {
return [config.aspect.toFixed(6), config.count, config.logoScale, config.sizeVariation, config.spacing, config.rotation, config.seed].join(":");
}
function updateLabels(config: Config): void {
element<HTMLOutputElement>("density-value").value = String(config.count);
element<HTMLOutputElement>("size-value").value = `${config.logoScale.toFixed(2)}×`;
element<HTMLOutputElement>("size-variation-value").value = `${Math.round(config.sizeVariation * 100)}%`;
element<HTMLOutputElement>("spacing-value").value = `${config.spacing.toFixed(2)}×`;
element<HTMLOutputElement>("rotation-value").value = `${Math.round(config.rotation)}°`;
element<HTMLSpanElement>("pixel-count").textContent = `${((config.width * config.height) / 1_000_000).toFixed(1)} MP`;
const label = config.width === 3840 && config.height === 2160 ? "4K" : `${config.width}×${config.height}`;
exportButton.textContent = `Render ${label} PNG`;
}
function renderPreview(): void {
framePending = false;
const symbol = selectedSymbol();
if (!symbol) return;
const started = performance.now();
const config = readConfig();
const key = configKey(config);
if (key !== currentConfigKey) {
currentLayout = generateLayout(config);
currentConfigKey = key;
}
updateLabels(config);
syncUrl(config);
const bounds = canvasWrap.getBoundingClientRect();
const cssWidth = Math.max(320, bounds.width);
const cssHeight = cssWidth / config.aspect;
const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
preview.style.aspectRatio = String(config.aspect);
preview.width = Math.max(1, Math.round(cssWidth * pixelRatio));
preview.height = Math.max(1, Math.round(cssHeight * pixelRatio));
const context = preview.getContext("2d", { alpha: false });
if (!context) throw new Error("Canvas 2D is not available");
renderWallpaper(context, preview.width, preview.height, currentLayout, symbol, false);
renderStats.textContent = `${config.count} symbols · ${(performance.now() - started).toFixed(1)} ms`;
}
function schedulePreview(): void {
if (framePending) return;
framePending = true;
requestAnimationFrame(renderPreview);
}
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<Blob> {
if (canvas instanceof OffscreenCanvas) return canvas.convertToBlob({ type: "image/png" });
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG encoding failed"))), "image/png");
});
}
async function exportPng(): Promise<void> {
const config = readConfig();
const pixels = config.width * config.height;
if (pixels > 33_177_600) {
exportStatus.textContent = "Maximum export size is 7680×4320 (33.2 MP).";
return;
}
exportButton.disabled = true;
exportButton.textContent = `Rendering ${config.width}×${config.height}…`;
exportStatus.textContent = "Drawing full-resolution canvas…";
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const started = performance.now();
try {
const symbol = selectedSymbol();
if (!symbol) throw new Error("The selected symbol is not ready");
const key = configKey(config);
const layout = key === currentConfigKey ? currentLayout : generateLayout(config);
const surface: HTMLCanvasElement | OffscreenCanvas =
typeof OffscreenCanvas === "undefined"
? Object.assign(document.createElement("canvas"), { width: config.width, height: config.height })
: new OffscreenCanvas(config.width, config.height);
const context = surface.getContext("2d", { alpha: false });
if (!context || !("drawImage" in context)) throw new Error("Canvas 2D is not available");
renderWallpaper(context, config.width, config.height, layout, symbol);
const blob = await canvasToBlob(surface);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `tilekit-${config.width}x${config.height}-seed-${config.seed}.png`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
const elapsed = performance.now() - started;
exportStatus.textContent = `${config.width}×${config.height} PNG · ${(blob.size / 1_000_000).toFixed(1)} MB · ${(elapsed / 1000).toFixed(2)} s`;
} catch (error) {
exportStatus.textContent = error instanceof Error ? error.message : "PNG export failed";
} finally {
exportButton.disabled = false;
updateLabels(config);
}
}
async function copyShareUrl(): Promise<void> {
const url = canonicalUrl();
syncUrl(readConfig());
try {
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(url.href);
else {
const textarea = document.createElement("textarea");
textarea.value = url.href;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
shareButton.textContent = "URL copied";
} catch {
shareButton.textContent = "Copy failed — use address bar";
}
window.setTimeout(() => {
shareButton.textContent = "Copy share URL";
}, 1800);
}
function setDefaults(): void {
resolution.value = "3840x2160";
widthInput.value = "3840";
heightInput.value = "2160";
selectedEmoji = "🤗";
selectedEmojiElement.textContent = selectedEmoji;
modeEmoji.textContent = selectedEmoji;
selectedEmojiName.textContent = "Hugging face";
setSymbolMode("logo");
densityInput.value = "88";
sizeInput.value = "1";
sizeVariationInput.value = "1";
spacingInput.value = "1";
rotationInput.value = "52";
seedInput.value = "84237";
schedulePreview();
}
resolution.addEventListener("change", () => {
if (resolution.value !== "custom") {
const [width, height] = resolution.value.split("x");
if (width && height) {
widthInput.value = width;
heightInput.value = height;
}
}
schedulePreview();
});
logoModeButton.addEventListener("click", () => setSymbolMode("logo"));
emojiModeButton.addEventListener("click", () => {
setSymbolMode("emoji");
emojiPickerPanel.hidden = false;
emojiPickerToggle.setAttribute("aria-expanded", "true");
void loadEmojiPicker();
});
emojiPickerToggle.addEventListener("click", () => {
const willOpen = emojiPickerPanel.hidden;
emojiPickerPanel.hidden = !willOpen;
emojiPickerToggle.setAttribute("aria-expanded", String(willOpen));
if (willOpen) void loadEmojiPicker();
});
for (const input of [widthInput, heightInput, densityInput, sizeInput, sizeVariationInput, spacingInput, rotationInput, seedInput]) {
input.addEventListener("input", () => {
if (input === widthInput || input === heightInput) resolution.value = "custom";
schedulePreview();
});
}
element<HTMLButtonElement>("randomize").addEventListener("click", () => {
const value = new Uint32Array(1);
crypto.getRandomValues(value);
seedInput.value = String(value[0] ?? 84237);
schedulePreview();
});
element<HTMLButtonElement>("reset").addEventListener("click", setDefaults);
exportButton.addEventListener("click", () => void exportPng());
shareButton.addEventListener("click", () => void copyShareUrl());
new ResizeObserver(schedulePreview).observe(canvasWrap);
restoreFromUrl();
const logoImage = new Image();
const logoUrl = URL.createObjectURL(new Blob([logoSvg], { type: "image/svg+xml" }));
logoImage.decoding = "async";
logoImage.addEventListener("load", () => {
logo = logoImage;
schedulePreview();
URL.revokeObjectURL(logoUrl);
if (typeof createImageBitmap === "function") {
void createImageBitmap(logoImage).then((bitmap) => {
logo = bitmap;
schedulePreview();
});
}
});
logoImage.addEventListener("error", () => {
renderStats.textContent = "Could not load logo";
});
logoImage.src = logoUrl;