| 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; |
|
|