feat: add shareable configuration URLs
Browse files- index.html +1 -0
- src/main.ts +82 -0
- src/style.css +2 -0
- src/url-state.test.ts +38 -0
- src/url-state.ts +106 -0
index.html
CHANGED
|
@@ -94,6 +94,7 @@
|
|
| 94 |
</div>
|
| 95 |
|
| 96 |
<button id="export" class="export-button">Render 4K PNG</button>
|
|
|
|
| 97 |
<p id="export-status" class="export-status" role="status">Rendered locally in your browser.</p>
|
| 98 |
</aside>
|
| 99 |
|
|
|
|
| 94 |
</div>
|
| 95 |
|
| 96 |
<button id="export" class="export-button">Render 4K PNG</button>
|
| 97 |
+
<button id="share" class="share-button" type="button">Copy share URL</button>
|
| 98 |
<p id="export-status" class="export-status" role="status">Rendered locally in your browser.</p>
|
| 99 |
</aside>
|
| 100 |
|
src/main.ts
CHANGED
|
@@ -2,6 +2,7 @@ import "./style.css";
|
|
| 2 |
import logoSvg from "../assets/hugging-face.svg?raw";
|
| 3 |
import { generateLayout, type LayoutOptions } from "./layout";
|
| 4 |
import { renderWallpaper, type WallpaperSymbol } from "./render";
|
|
|
|
| 5 |
|
| 6 |
interface Config extends LayoutOptions {
|
| 7 |
width: number;
|
|
@@ -34,6 +35,7 @@ const spacingInput = element<HTMLInputElement>("spacing");
|
|
| 34 |
const rotationInput = element<HTMLInputElement>("rotation");
|
| 35 |
const seedInput = element<HTMLInputElement>("seed");
|
| 36 |
const exportButton = element<HTMLButtonElement>("export");
|
|
|
|
| 37 |
const exportStatus = element<HTMLParagraphElement>("export-status");
|
| 38 |
const renderStats = element<HTMLSpanElement>("render-stats");
|
| 39 |
|
|
@@ -111,6 +113,58 @@ function readConfig(): Config {
|
|
| 111 |
};
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
function configKey(config: Config): string {
|
| 115 |
return [config.aspect.toFixed(6), config.count, config.logoScale, config.sizeVariation, config.spacing, config.rotation, config.seed].join(":");
|
| 116 |
}
|
|
@@ -138,6 +192,7 @@ function renderPreview(): void {
|
|
| 138 |
currentConfigKey = key;
|
| 139 |
}
|
| 140 |
updateLabels(config);
|
|
|
|
| 141 |
|
| 142 |
const bounds = canvasWrap.getBoundingClientRect();
|
| 143 |
const cssWidth = Math.max(320, bounds.width);
|
|
@@ -208,6 +263,30 @@ async function exportPng(): Promise<void> {
|
|
| 208 |
}
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
function setDefaults(): void {
|
| 212 |
resolution.value = "3840x2160";
|
| 213 |
widthInput.value = "3840";
|
|
@@ -266,8 +345,11 @@ element<HTMLButtonElement>("randomize").addEventListener("click", () => {
|
|
| 266 |
});
|
| 267 |
element<HTMLButtonElement>("reset").addEventListener("click", setDefaults);
|
| 268 |
exportButton.addEventListener("click", () => void exportPng());
|
|
|
|
| 269 |
new ResizeObserver(schedulePreview).observe(canvasWrap);
|
| 270 |
|
|
|
|
|
|
|
| 271 |
const logoImage = new Image();
|
| 272 |
const logoUrl = URL.createObjectURL(new Blob([logoSvg], { type: "image/svg+xml" }));
|
| 273 |
logoImage.decoding = "async";
|
|
|
|
| 2 |
import logoSvg from "../assets/hugging-face.svg?raw";
|
| 3 |
import { generateLayout, type LayoutOptions } from "./layout";
|
| 4 |
import { renderWallpaper, type WallpaperSymbol } from "./render";
|
| 5 |
+
import { decodeShareState, encodeShareState, type ShareState } from "./url-state";
|
| 6 |
|
| 7 |
interface Config extends LayoutOptions {
|
| 8 |
width: number;
|
|
|
|
| 35 |
const rotationInput = element<HTMLInputElement>("rotation");
|
| 36 |
const seedInput = element<HTMLInputElement>("seed");
|
| 37 |
const exportButton = element<HTMLButtonElement>("export");
|
| 38 |
+
const shareButton = element<HTMLButtonElement>("share");
|
| 39 |
const exportStatus = element<HTMLParagraphElement>("export-status");
|
| 40 |
const renderStats = element<HTMLSpanElement>("render-stats");
|
| 41 |
|
|
|
|
| 113 |
};
|
| 114 |
}
|
| 115 |
|
| 116 |
+
function currentShareState(config = readConfig()): ShareState {
|
| 117 |
+
return {
|
| 118 |
+
width: config.width,
|
| 119 |
+
height: config.height,
|
| 120 |
+
symbol: symbolMode === "logo" ? "hf" : selectedEmoji,
|
| 121 |
+
density: config.count,
|
| 122 |
+
size: config.logoScale,
|
| 123 |
+
variation: config.sizeVariation,
|
| 124 |
+
spacing: config.spacing,
|
| 125 |
+
rotation: config.rotation,
|
| 126 |
+
seed: config.seed,
|
| 127 |
+
};
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function canonicalUrl(config = readConfig()): URL {
|
| 131 |
+
const url = new URL(window.location.href);
|
| 132 |
+
url.search = encodeShareState(currentShareState(config));
|
| 133 |
+
url.hash = "";
|
| 134 |
+
return url;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function syncUrl(config: Config): void {
|
| 138 |
+
window.history.replaceState(null, "", canonicalUrl(config));
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function restoreFromUrl(): void {
|
| 142 |
+
const state = decodeShareState(window.location.search);
|
| 143 |
+
if (state.width !== undefined && state.height !== undefined) {
|
| 144 |
+
widthInput.value = String(state.width);
|
| 145 |
+
heightInput.value = String(state.height);
|
| 146 |
+
const preset = `${state.width}x${state.height}`;
|
| 147 |
+
resolution.value = [...resolution.options].some((option) => option.value === preset) ? preset : "custom";
|
| 148 |
+
}
|
| 149 |
+
if (state.symbol !== undefined) {
|
| 150 |
+
if (state.symbol === "hf") setSymbolMode("logo");
|
| 151 |
+
else {
|
| 152 |
+
selectedEmoji = state.symbol;
|
| 153 |
+
selectedEmojiElement.textContent = selectedEmoji;
|
| 154 |
+
modeEmoji.textContent = selectedEmoji;
|
| 155 |
+
selectedEmojiName.textContent = "Emoji from URL";
|
| 156 |
+
setSymbolMode("emoji");
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
if (state.density !== undefined) densityInput.value = String(state.density);
|
| 160 |
+
if (state.size !== undefined) sizeInput.value = String(state.size);
|
| 161 |
+
if (state.variation !== undefined) sizeVariationInput.value = String(state.variation);
|
| 162 |
+
if (state.spacing !== undefined) spacingInput.value = String(state.spacing);
|
| 163 |
+
if (state.rotation !== undefined) rotationInput.value = String(state.rotation);
|
| 164 |
+
if (state.seed !== undefined) seedInput.value = String(state.seed);
|
| 165 |
+
schedulePreview();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
function configKey(config: Config): string {
|
| 169 |
return [config.aspect.toFixed(6), config.count, config.logoScale, config.sizeVariation, config.spacing, config.rotation, config.seed].join(":");
|
| 170 |
}
|
|
|
|
| 192 |
currentConfigKey = key;
|
| 193 |
}
|
| 194 |
updateLabels(config);
|
| 195 |
+
syncUrl(config);
|
| 196 |
|
| 197 |
const bounds = canvasWrap.getBoundingClientRect();
|
| 198 |
const cssWidth = Math.max(320, bounds.width);
|
|
|
|
| 263 |
}
|
| 264 |
}
|
| 265 |
|
| 266 |
+
async function copyShareUrl(): Promise<void> {
|
| 267 |
+
const url = canonicalUrl();
|
| 268 |
+
syncUrl(readConfig());
|
| 269 |
+
try {
|
| 270 |
+
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(url.href);
|
| 271 |
+
else {
|
| 272 |
+
const textarea = document.createElement("textarea");
|
| 273 |
+
textarea.value = url.href;
|
| 274 |
+
textarea.style.position = "fixed";
|
| 275 |
+
textarea.style.opacity = "0";
|
| 276 |
+
document.body.append(textarea);
|
| 277 |
+
textarea.select();
|
| 278 |
+
document.execCommand("copy");
|
| 279 |
+
textarea.remove();
|
| 280 |
+
}
|
| 281 |
+
shareButton.textContent = "URL copied";
|
| 282 |
+
} catch {
|
| 283 |
+
shareButton.textContent = "Copy failed — use address bar";
|
| 284 |
+
}
|
| 285 |
+
window.setTimeout(() => {
|
| 286 |
+
shareButton.textContent = "Copy share URL";
|
| 287 |
+
}, 1800);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
function setDefaults(): void {
|
| 291 |
resolution.value = "3840x2160";
|
| 292 |
widthInput.value = "3840";
|
|
|
|
| 345 |
});
|
| 346 |
element<HTMLButtonElement>("reset").addEventListener("click", setDefaults);
|
| 347 |
exportButton.addEventListener("click", () => void exportPng());
|
| 348 |
+
shareButton.addEventListener("click", () => void copyShareUrl());
|
| 349 |
new ResizeObserver(schedulePreview).observe(canvasWrap);
|
| 350 |
|
| 351 |
+
restoreFromUrl();
|
| 352 |
+
|
| 353 |
const logoImage = new Image();
|
| 354 |
const logoUrl = URL.createObjectURL(new Blob([logoSvg], { type: "image/svg+xml" }));
|
| 355 |
logoImage.decoding = "async";
|
src/style.css
CHANGED
|
@@ -53,6 +53,8 @@ input[type="range"] { width: 100%; margin: 0; cursor: ew-resize; }
|
|
| 53 |
.export-button { width: 100%; border: 0; border-radius: 12px; margin-top: 18px; padding: 12px 16px; color: #2d2706; font-weight: 850; cursor: pointer; background: linear-gradient(135deg, #ffd21e, #ffad03); box-shadow: 0 8px 22px rgba(255,173,3,0.25); }
|
| 54 |
.export-button:hover { filter: brightness(1.03); transform: translateY(-1px); }
|
| 55 |
.export-button:disabled { cursor: wait; opacity: 0.65; transform: none; }
|
|
|
|
|
|
|
| 56 |
.export-status { min-height: 2.4em; margin: 9px 2px 0; color: #718894; font-size: 0.72rem; line-height: 1.35; }
|
| 57 |
|
| 58 |
.preview-panel { overflow: hidden; border-radius: 20px; }
|
|
|
|
| 53 |
.export-button { width: 100%; border: 0; border-radius: 12px; margin-top: 18px; padding: 12px 16px; color: #2d2706; font-weight: 850; cursor: pointer; background: linear-gradient(135deg, #ffd21e, #ffad03); box-shadow: 0 8px 22px rgba(255,173,3,0.25); }
|
| 54 |
.export-button:hover { filter: brightness(1.03); transform: translateY(-1px); }
|
| 55 |
.export-button:disabled { cursor: wait; opacity: 0.65; transform: none; }
|
| 56 |
+
.share-button { width: 100%; margin-top: 8px; padding: 10px 14px; border: 1px solid #b9d2df; border-radius: 11px; color: #23536f; background: #eef7fb; cursor: pointer; font-size: 0.78rem; font-weight: 800; }
|
| 57 |
+
.share-button:hover { border-color: #0d82df; color: #0d73c7; background: #e5f4fc; }
|
| 58 |
.export-status { min-height: 2.4em; margin: 9px 2px 0; color: #718894; font-size: 0.72rem; line-height: 1.35; }
|
| 59 |
|
| 60 |
.preview-panel { overflow: hidden; border-radius: 20px; }
|
src/url-state.test.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { decodeShareState, DEFAULT_SHARE_STATE, encodeShareState, type ShareState } from "./url-state";
|
| 3 |
+
|
| 4 |
+
describe("share URLs", () => {
|
| 5 |
+
it("keeps the default URL clean", () => {
|
| 6 |
+
expect(encodeShareState(DEFAULT_SHARE_STATE)).toBe("");
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
it("uses readable names and compact values", () => {
|
| 10 |
+
const state: ShareState = {
|
| 11 |
+
width: 5120,
|
| 12 |
+
height: 2880,
|
| 13 |
+
symbol: "🦋",
|
| 14 |
+
density: 160,
|
| 15 |
+
size: 1.2,
|
| 16 |
+
variation: 0.8,
|
| 17 |
+
spacing: 1.1,
|
| 18 |
+
rotation: 35,
|
| 19 |
+
seed: 42,
|
| 20 |
+
};
|
| 21 |
+
expect(decodeShareState(encodeShareState(state))).toEqual(state);
|
| 22 |
+
expect(decodeURIComponent(encodeShareState(state))).toBe(
|
| 23 |
+
"canvas=5k&symbol=🦋&density=160&size=1.2&variation=0.8&spacing=1.1&rotation=35&seed=42",
|
| 24 |
+
);
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it("supports custom canvas sizes and joined emoji", () => {
|
| 28 |
+
expect(decodeShareState("?canvas=1440x900&symbol=👨👩👧👦")).toEqual({
|
| 29 |
+
width: 1440,
|
| 30 |
+
height: 900,
|
| 31 |
+
symbol: "👨👩👧👦",
|
| 32 |
+
});
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
it("ignores unknown and out-of-range values", () => {
|
| 36 |
+
expect(decodeShareState("?canvas=10x10&density=900&size=nope&other=1")).toEqual({});
|
| 37 |
+
});
|
| 38 |
+
});
|
src/url-state.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface ShareState {
|
| 2 |
+
width: number;
|
| 3 |
+
height: number;
|
| 4 |
+
symbol: string;
|
| 5 |
+
density: number;
|
| 6 |
+
size: number;
|
| 7 |
+
variation: number;
|
| 8 |
+
spacing: number;
|
| 9 |
+
rotation: number;
|
| 10 |
+
seed: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const DEFAULT_SHARE_STATE: ShareState = {
|
| 14 |
+
width: 3840,
|
| 15 |
+
height: 2160,
|
| 16 |
+
symbol: "hf",
|
| 17 |
+
density: 88,
|
| 18 |
+
size: 1,
|
| 19 |
+
variation: 1,
|
| 20 |
+
spacing: 1,
|
| 21 |
+
rotation: 52,
|
| 22 |
+
seed: 84237,
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const CANVAS_ALIASES = new Map<string, readonly [number, number]>([
|
| 26 |
+
["1080p", [1920, 1080]],
|
| 27 |
+
["1440p", [2560, 1440]],
|
| 28 |
+
["4k", [3840, 2160]],
|
| 29 |
+
["5k", [5120, 2880]],
|
| 30 |
+
["8k", [7680, 4320]],
|
| 31 |
+
]);
|
| 32 |
+
|
| 33 |
+
const CANVAS_NAMES = new Map([...CANVAS_ALIASES].map(([name, dimensions]) => [`${dimensions[0]}x${dimensions[1]}`, name]));
|
| 34 |
+
|
| 35 |
+
function compactNumber(value: number): string {
|
| 36 |
+
return String(Number(value.toFixed(2)));
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function firstGrapheme(value: string): string | undefined {
|
| 40 |
+
const trimmed = value.trim();
|
| 41 |
+
if (!trimmed) return undefined;
|
| 42 |
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
| 43 |
+
return segmenter.segment(trimmed)[Symbol.iterator]().next().value?.segment;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function readNumber(params: URLSearchParams, name: string, minimum: number, maximum: number, integer = false): number | undefined {
|
| 47 |
+
const raw = params.get(name);
|
| 48 |
+
if (raw === null || raw.trim() === "") return undefined;
|
| 49 |
+
const parsed = Number(raw);
|
| 50 |
+
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) return undefined;
|
| 51 |
+
return integer ? Math.round(parsed) : Math.round(parsed * 100) / 100;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function readCanvas(value: string | null): readonly [number, number] | undefined {
|
| 55 |
+
if (!value) return undefined;
|
| 56 |
+
const alias = CANVAS_ALIASES.get(value.toLowerCase());
|
| 57 |
+
if (alias) return alias;
|
| 58 |
+
const match = /^(\d{3,4})x(\d{3,4})$/i.exec(value);
|
| 59 |
+
if (!match) return undefined;
|
| 60 |
+
const width = Number(match[1]);
|
| 61 |
+
const height = Number(match[2]);
|
| 62 |
+
if (width < 640 || width > 7680 || height < 360 || height > 4320) return undefined;
|
| 63 |
+
return [width, height];
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function encodeShareState(state: ShareState): string {
|
| 67 |
+
const params = new URLSearchParams();
|
| 68 |
+
if (state.width !== DEFAULT_SHARE_STATE.width || state.height !== DEFAULT_SHARE_STATE.height) {
|
| 69 |
+
const dimensions = `${state.width}x${state.height}`;
|
| 70 |
+
params.set("canvas", CANVAS_NAMES.get(dimensions) ?? dimensions);
|
| 71 |
+
}
|
| 72 |
+
if (state.symbol !== DEFAULT_SHARE_STATE.symbol) params.set("symbol", state.symbol);
|
| 73 |
+
if (state.density !== DEFAULT_SHARE_STATE.density) params.set("density", String(Math.round(state.density)));
|
| 74 |
+
if (state.size !== DEFAULT_SHARE_STATE.size) params.set("size", compactNumber(state.size));
|
| 75 |
+
if (state.variation !== DEFAULT_SHARE_STATE.variation) params.set("variation", compactNumber(state.variation));
|
| 76 |
+
if (state.spacing !== DEFAULT_SHARE_STATE.spacing) params.set("spacing", compactNumber(state.spacing));
|
| 77 |
+
if (state.rotation !== DEFAULT_SHARE_STATE.rotation) params.set("rotation", String(Math.round(state.rotation)));
|
| 78 |
+
if (state.seed !== DEFAULT_SHARE_STATE.seed) params.set("seed", String(Math.round(state.seed) >>> 0));
|
| 79 |
+
return params.toString();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export function decodeShareState(search: string): Partial<ShareState> {
|
| 83 |
+
const params = new URLSearchParams(search);
|
| 84 |
+
const state: Partial<ShareState> = {};
|
| 85 |
+
const canvas = readCanvas(params.get("canvas"));
|
| 86 |
+
if (canvas) [state.width, state.height] = canvas;
|
| 87 |
+
|
| 88 |
+
const symbol = params.get("symbol");
|
| 89 |
+
if (symbol === "hf") state.symbol = "hf";
|
| 90 |
+
else if (symbol) {
|
| 91 |
+
const emoji = firstGrapheme(symbol);
|
| 92 |
+
if (emoji) state.symbol = emoji;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
state.density = readNumber(params, "density", 24, 512, true);
|
| 96 |
+
state.size = readNumber(params, "size", 0.5, 1.6);
|
| 97 |
+
state.variation = readNumber(params, "variation", 0, 1.5);
|
| 98 |
+
state.spacing = readNumber(params, "spacing", 0.25, 2);
|
| 99 |
+
state.rotation = readNumber(params, "rotation", 0, 70, true);
|
| 100 |
+
state.seed = readNumber(params, "seed", 0, 4294967295, true);
|
| 101 |
+
|
| 102 |
+
for (const key of Object.keys(state) as (keyof ShareState)[]) {
|
| 103 |
+
if (state[key] === undefined) delete state[key];
|
| 104 |
+
}
|
| 105 |
+
return state;
|
| 106 |
+
}
|