osolmaz HF Staff commited on
Commit
7614202
·
verified ·
1 Parent(s): eb570af

feat: add shareable configuration URLs

Browse files
Files changed (5) hide show
  1. index.html +1 -0
  2. src/main.ts +82 -0
  3. src/style.css +2 -0
  4. src/url-state.test.ts +38 -0
  5. 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
+ }