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(id: string): T { const value = document.getElementById(id); if (!value) throw new Error(`Missing element: ${id}`); return value as T; } const preview = element("preview"); const canvasWrap = element("canvas-wrap"); const resolution = element("resolution"); const widthInput = element("width"); const heightInput = element("height"); const logoModeButton = element("logo-mode"); const emojiModeButton = element("emoji-mode"); const emojiControls = element("emoji-controls"); const emojiPickerToggle = element("emoji-picker-toggle"); const emojiPickerPanel = element("emoji-picker-panel"); const selectedEmojiElement = element("selected-emoji"); const selectedEmojiName = element("selected-emoji-name"); const modeEmoji = element("mode-emoji"); const densityInput = element("density"); const sizeInput = element("logo-size"); const sizeVariationInput = element("size-variation"); const spacingInput = element("spacing"); const rotationInput = element("rotation"); const seedInput = element("seed"); const exportButton = element("export"); const shareButton = element("share"); const exportStatus = element("export-status"); const renderStats = element("render-stats"); let logo: CanvasImageSource | undefined; let currentLayout: Float32Array = new Float32Array(); let currentConfigKey = ""; let framePending = false; let symbolMode: "logo" | "emoji" = "logo"; let selectedEmoji = "🤗"; let pickerLoading: Promise | 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 { 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("density-value").value = String(config.count); element("size-value").value = `${config.logoScale.toFixed(2)}×`; element("size-variation-value").value = `${Math.round(config.sizeVariation * 100)}%`; element("spacing-value").value = `${config.spacing.toFixed(2)}×`; element("rotation-value").value = `${Math.round(config.rotation)}°`; element("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 { 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 { 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((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 { 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("randomize").addEventListener("click", () => { const value = new Uint32Array(1); crypto.getRandomValues(value); seedInput.value = String(value[0] ?? 84237); schedulePreview(); }); element("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;