wop commited on
Commit
e01f393
·
verified ·
1 Parent(s): 6e5ba59

Create script.js

Browse files
Files changed (1) hide show
  1. script.js +304 -0
script.js ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (() => {
2
+ document.addEventListener("DOMContentLoaded", () => {
3
+ const bg = document.querySelector(".background");
4
+
5
+ // ---- Inject realistic clouds into .background ----
6
+ if (bg && !bg.querySelector(".cloud")) {
7
+ const isMobile = window.matchMedia("(max-width:768px)").matches;
8
+ if (!isMobile && !document.getElementById("cloud-fluff")) {
9
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "svg");
10
+ defs.setAttribute("width", "0");
11
+ defs.setAttribute("height", "0");
12
+ defs.setAttribute("aria-hidden", "true");
13
+ defs.style.position = "absolute";
14
+ defs.innerHTML =
15
+ '<filter id="cloud-fluff" x="-150%" y="-150%" width="400%" height="400%">' +
16
+ '<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="4" seed="11" result="noise"/>' +
17
+ '<feDisplacementMap in="SourceGraphic" in2="noise" scale="120"/>' +
18
+ "</filter>";
19
+ document.body.appendChild(defs);
20
+ }
21
+ const cloudCount = isMobile ? 2 : 4;
22
+ for (let i = 1; i <= cloudCount; i++) {
23
+ const cloud = document.createElement("div");
24
+ cloud.className = "cloud cloud-" + i;
25
+ cloud.setAttribute("aria-hidden", "true");
26
+ bg.appendChild(cloud);
27
+ }
28
+ }
29
+
30
+ // ---- Grass strip along the bottom of the viewport ----
31
+ if (!document.querySelector(".grass")) {
32
+ const NS = "http://www.w3.org/2000/svg";
33
+ const W = isMobile ? 1200 : 2400;
34
+ const H = 70;
35
+ const svg = document.createElementNS(NS, "svg");
36
+ svg.setAttribute("class", "grass");
37
+ svg.setAttribute("viewBox", "0 0 " + W + " " + H);
38
+ svg.setAttribute("preserveAspectRatio", "xMidYMax slice");
39
+ svg.setAttribute("aria-hidden", "true");
40
+
41
+ // soft ground shading under the blades
42
+ svg.innerHTML =
43
+ '<defs><linearGradient id="grass-base" x1="0" y1="1" x2="0" y2="0">' +
44
+ '<stop offset="0" stop-color="#3e7a46" stop-opacity="0.55"/>' +
45
+ '<stop offset="1" stop-color="#3e7a46" stop-opacity="0"/>' +
46
+ "</linearGradient></defs>" +
47
+ '<rect x="0" y="' + (H - 16) + '" width="' + W + '" height="16" fill="url(#grass-base)"/>';
48
+
49
+ const isMobile = window.matchMedia("(max-width: 768px)").matches;
50
+
51
+ const layers = isMobile
52
+ ? [
53
+ { count: 50, hMin: 18, hMax: 32, colors: ["#4f9854"], opacity: 0.9 },
54
+ { count: 40, hMin: 24, hMax: 42, colors: ["#63ad64"], opacity: 1 },
55
+ ]
56
+ : [
57
+ { count: 190, hMin: 18, hMax: 36, colors: ["#3e7a46","#457f4b","#4a8a50"], opacity: 0.8 },
58
+ { count: 160, hMin: 26, hMax: 52, colors: ["#4f9854","#57a05a","#63ad64","#6db36e"], opacity: 1 },
59
+ ];
60
+
61
+ const layerEls = [];
62
+ layers.forEach((layer) => {
63
+ const g = document.createElementNS(NS, "g");
64
+ g.setAttribute("opacity", layer.opacity);
65
+ g.style.transformBox = "fill-box";
66
+ g.style.transformOrigin = "50% 100%";
67
+ layerEls.push(g);
68
+ for (let i = 0; i < layer.count; i++) {
69
+ const x = Math.random() * W;
70
+ const h = layer.hMin + Math.random() * (layer.hMax - layer.hMin);
71
+ const lean = (Math.random() - 0.5) * 16;
72
+ const w = 3 + Math.random() * 3;
73
+ const color = layer.colors[(Math.random() * layer.colors.length) | 0];
74
+ const blade = document.createElementNS(NS, "path");
75
+ blade.setAttribute(
76
+ "d",
77
+ "M " + x + " " + H +
78
+ " Q " + (x + lean * 0.2) + " " + (H - h * 0.6) + " " + (x + lean) + " " + (H - h) +
79
+ " Q " + (x + lean * 0.6 + w * 0.5) + " " + (H - h * 0.55) + " " + (x + w) + " " + H + " Z"
80
+ );
81
+ blade.setAttribute("fill", color);
82
+ blade.style.transformBox = "fill-box";
83
+ blade.style.transformOrigin = "50% 100%";
84
+ // stagger by x so a gust appears to travel across the lawn
85
+ if (!isMobile) {
86
+ blade.style.animation =
87
+ "blade-sway " + (2.6 + Math.random() * 2.4).toFixed(2) + "s ease-in-out " +
88
+ (-(x / W) * 3 - Math.random()).toFixed(2) + "s infinite alternate";
89
+ }
90
+ g.appendChild(blade);
91
+ }
92
+ svg.appendChild(g);
93
+ });
94
+
95
+ document.body.appendChild(svg);
96
+
97
+ // ---- Scroll physics: scrolling down squashes the grass toward the
98
+ // ground, scrolling up stretches it, and a damped spring makes it
99
+ // boing back upright ----
100
+ if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
101
+ let squash = 0; // offset from natural height (scaleY = 1 + squash)
102
+ let squashVel = 0;
103
+ let lastY = window.scrollY;
104
+ let raf = null;
105
+
106
+ const settle = () => {
107
+ squashVel -= squash * 0.06; // spring pull toward natural height
108
+ squashVel *= 0.88; // damping
109
+ squash += squashVel;
110
+ const s = Math.max(-0.3, Math.min(0.14, squash));
111
+ layerEls.forEach((g, i) => {
112
+ // back layer reacts less than the front for depth
113
+ const depth = i === 0 ? 0.55 : 1;
114
+ g.style.transform = "scaleY(" + (1 + s * depth).toFixed(4) + ")";
115
+ });
116
+ if (Math.abs(squash) > 0.001 || Math.abs(squashVel) > 0.001) {
117
+ raf = requestAnimationFrame(settle);
118
+ } else {
119
+ layerEls.forEach((g) => (g.style.transform = ""));
120
+ raf = null;
121
+ }
122
+ };
123
+
124
+ const impulse = (v) => {
125
+ squashVel += v;
126
+ if (!raf) raf = requestAnimationFrame(settle);
127
+ };
128
+
129
+ window.addEventListener(
130
+ "scroll",
131
+ () => {
132
+ const y = window.scrollY;
133
+ // scroll down -> stretch, scroll up -> duck
134
+ impulse(Math.max(-0.08, Math.min(0.08, (y - lastY) * 0.0015)));
135
+ lastY = y;
136
+ },
137
+ { passive: true }
138
+ );
139
+
140
+ // wheel fallback so short pages that can't scroll still react
141
+ window.addEventListener(
142
+ "wheel",
143
+ (e) => {
144
+ if (document.documentElement.scrollHeight <= window.innerHeight + 4) {
145
+ impulse(Math.max(-0.05, Math.min(0.05, e.deltaY * 0.0005)));
146
+ }
147
+ },
148
+ { passive: true }
149
+ );
150
+ }
151
+ }
152
+
153
+ // ---- Copy buttons on <pre> ----
154
+ document.querySelectorAll("pre").forEach((pre) => {
155
+ if (pre.querySelector(".copy-btn")) return;
156
+ const code = pre.querySelector("code") || pre;
157
+ const btn = document.createElement("button");
158
+ btn.className = "copy-btn";
159
+ btn.textContent = "Copy";
160
+ btn.addEventListener("click", async () => {
161
+ try {
162
+ await navigator.clipboard.writeText(code.innerText);
163
+ btn.textContent = "Copied!";
164
+ setTimeout(() => (btn.textContent = "Copy"), 1200);
165
+ } catch {
166
+ btn.textContent = "Error";
167
+ setTimeout(() => (btn.textContent = "Copy"), 1200);
168
+ }
169
+ });
170
+ pre.appendChild(btn);
171
+ });
172
+
173
+ // ---- Article-only features: sidebar + collapsible code ----
174
+ const article = document.querySelector(".article");
175
+ if (!article) return;
176
+
177
+ // Collect headings for TOC
178
+ const headings = article.querySelectorAll("h1, h2, h3");
179
+ const codeBlocks = article.querySelectorAll("pre");
180
+
181
+ // Determine filenames from preceding h2
182
+ const codeFiles = [];
183
+ codeBlocks.forEach((pre, idx) => {
184
+ let name = null;
185
+ let el = pre.previousElementSibling;
186
+ while (el) {
187
+ if (el.tagName === "H2" || el.tagName === "H3") {
188
+ const text = el.textContent.trim();
189
+ if (text.match(/\.\w{1,5}$/) || text.match(/\.\w{1,5}\s/)) {
190
+ name = text.replace(/\s*\(.*\)/, "").trim();
191
+ }
192
+ break;
193
+ }
194
+ el = el.previousElementSibling;
195
+ }
196
+ if (!name) name = `code-block-${idx + 1}.txt`;
197
+ codeFiles.push({ name, pre });
198
+ });
199
+
200
+ // Make code blocks collapsible
201
+ codeBlocks.forEach((pre, idx) => {
202
+ const wrapper = document.createElement("div");
203
+ wrapper.className = "code-collapsible";
204
+
205
+ const toggle = document.createElement("button");
206
+ toggle.className = "code-toggle";
207
+ const fileName = codeFiles[idx].name;
208
+ toggle.innerHTML = `<span class="code-toggle-arrow">▶</span> <span class="code-toggle-name">${fileName}</span>`;
209
+ toggle.setAttribute("aria-expanded", "false");
210
+
211
+ pre.parentNode.insertBefore(wrapper, pre);
212
+ wrapper.appendChild(toggle);
213
+ wrapper.appendChild(pre);
214
+ pre.classList.add("collapsed");
215
+
216
+ toggle.addEventListener("click", () => {
217
+ const expanded = pre.classList.toggle("collapsed");
218
+ toggle.setAttribute("aria-expanded", !expanded);
219
+ toggle.querySelector(".code-toggle-arrow").textContent = expanded ? "▶" : "▼";
220
+ });
221
+ });
222
+
223
+ // Build sidebar
224
+ const sidebar = document.createElement("nav");
225
+ sidebar.className = "article-sidebar";
226
+
227
+ // TOC section
228
+ const tocTitle = document.createElement("div");
229
+ tocTitle.className = "sidebar-title";
230
+ tocTitle.textContent = "Contents";
231
+ sidebar.appendChild(tocTitle);
232
+
233
+ const tocList = document.createElement("ul");
234
+ tocList.className = "sidebar-toc";
235
+
236
+ headings.forEach((h, i) => {
237
+ if (!h.id) h.id = "heading-" + i;
238
+ const li = document.createElement("li");
239
+ li.className = "toc-" + h.tagName.toLowerCase();
240
+ const a = document.createElement("a");
241
+ a.href = "#" + h.id;
242
+ a.textContent = h.textContent;
243
+ a.addEventListener("click", (e) => {
244
+ e.preventDefault();
245
+ h.scrollIntoView({ behavior: "smooth", block: "start" });
246
+ });
247
+ li.appendChild(a);
248
+ tocList.appendChild(li);
249
+ });
250
+ sidebar.appendChild(tocList);
251
+
252
+ // Files section
253
+ if (codeFiles.length > 0) {
254
+ const filesTitle = document.createElement("div");
255
+ filesTitle.className = "sidebar-title";
256
+ filesTitle.textContent = "Files";
257
+ sidebar.appendChild(filesTitle);
258
+
259
+ const fileList = document.createElement("ul");
260
+ fileList.className = "sidebar-files";
261
+
262
+ codeFiles.forEach(({ name, pre }) => {
263
+ const li = document.createElement("li");
264
+ const btn = document.createElement("button");
265
+ btn.className = "sidebar-file-btn";
266
+ btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> ${name}`;
267
+
268
+ btn.addEventListener("click", () => {
269
+ const code = pre.querySelector("code") || pre;
270
+ const text = code.innerText;
271
+ const blob = new Blob([text], { type: "text/plain" });
272
+ const url = URL.createObjectURL(blob);
273
+ const a = document.createElement("a");
274
+ a.href = url;
275
+ a.download = name;
276
+ a.click();
277
+ URL.revokeObjectURL(url);
278
+ });
279
+
280
+ li.appendChild(btn);
281
+ fileList.appendChild(li);
282
+ });
283
+ sidebar.appendChild(fileList);
284
+ }
285
+
286
+ document.body.appendChild(sidebar);
287
+
288
+ // Highlight active TOC item on scroll
289
+ const tocLinks = tocList.querySelectorAll("a");
290
+ const observer = new IntersectionObserver(
291
+ (entries) => {
292
+ entries.forEach((entry) => {
293
+ if (entry.isIntersecting) {
294
+ tocLinks.forEach((l) => l.classList.remove("active"));
295
+ const link = tocList.querySelector(`a[href="#${entry.target.id}"]`);
296
+ if (link) link.classList.add("active");
297
+ }
298
+ });
299
+ },
300
+ { rootMargin: "-80px 0px -70% 0px" }
301
+ );
302
+ headings.forEach((h) => observer.observe(h));
303
+ });
304
+ })();