fix:新增功能并优化
This commit is contained in:
128
display.js
128
display.js
@@ -13,7 +13,27 @@ const backBtn = document.getElementById("back-btn");
|
||||
|
||||
const API_BASE = "/api/shaders";
|
||||
|
||||
/** 串行补全缺失缩略图(每步独立 WebGL,用完即释放),避免多卡片同时建上下文 */
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function getVisitorId() {
|
||||
try {
|
||||
let v = localStorage.getItem("sv_vid");
|
||||
if (!v) {
|
||||
v = crypto.randomUUID();
|
||||
localStorage.setItem("sv_vid", v);
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
return "anon";
|
||||
}
|
||||
}
|
||||
|
||||
const thumbBackfillQueue = [];
|
||||
const thumbBackfillQueuedIds = new Set();
|
||||
const thumbBackfillFailUntil = new Map();
|
||||
@@ -85,11 +105,9 @@ function replaceCardStageWithThumb(id, url) {
|
||||
stage.innerHTML = `<img class="card-thumb" src="${url}" alt="" loading="lazy" decoding="async" />`;
|
||||
}
|
||||
|
||||
/** 全屏详情:限制像素量以便稳定 50+ FPS(过高 DPR × 边长会显著拖慢片元着色器) */
|
||||
const DETAIL_MAX_PIXEL_RATIO = 1.35;
|
||||
const DETAIL_MAX_BUFFER_LONG_SIDE = 1680;
|
||||
|
||||
/** 卡片 hover 实时预览:单例 WebGL,与缩略图同量级分辨率上限 */
|
||||
const HOVER_MAX_PIXEL_RATIO = 1.25;
|
||||
const HOVER_MAX_BUFFER_LONG_SIDE = 560;
|
||||
|
||||
@@ -135,7 +153,6 @@ function toPortableGlsl(code) {
|
||||
.replace(/ANGLE_elem_ref\(([^)]+)\)/g, "$1")
|
||||
.replace(/(\d+\.\d+|\d+)f\b/g, "$1");
|
||||
|
||||
// Keep only helper functions + mainImage block, drop struct/template leftovers.
|
||||
const mi = s.indexOf("void mainImage");
|
||||
if (mi >= 0) {
|
||||
const head = s.slice(0, mi);
|
||||
@@ -267,7 +284,6 @@ function createTexture(gl, width, height, data) {
|
||||
}
|
||||
|
||||
function createDefaultChannelTextures(gl, noiseSize = 256) {
|
||||
// iChannel0: audio-like lookup texture (matches many shadertoy audio assumptions)
|
||||
const w0 = 512;
|
||||
const h0 = 2;
|
||||
const d0 = new Uint8Array(w0 * h0 * 4);
|
||||
@@ -275,14 +291,12 @@ function createDefaultChannelTextures(gl, noiseSize = 256) {
|
||||
const t = x / (w0 - 1);
|
||||
const fft = Math.floor(255 * Math.pow(1.0 - t, 1.8));
|
||||
const wave = Math.floor(128 + 127 * Math.sin(t * Math.PI * 10.0));
|
||||
// row0: fft, row1: waveform
|
||||
const i0 = (x + 0 * w0) * 4;
|
||||
const i1 = (x + 1 * w0) * 4;
|
||||
d0[i0] = fft; d0[i0 + 1] = fft; d0[i0 + 2] = fft; d0[i0 + 3] = 255;
|
||||
d0[i1] = wave; d0[i1 + 1] = wave; d0[i1 + 2] = wave; d0[i1 + 3] = 255;
|
||||
}
|
||||
|
||||
// iChannel1/2/3: tiling noise/grain-like textures (smaller in grid previews).
|
||||
const size = noiseSize;
|
||||
const makeNoise = (scaleA, scaleB) => {
|
||||
const d = new Uint8Array(size * size * 4);
|
||||
@@ -349,7 +363,6 @@ function computeHoverBufferSize(cssW, cssH) {
|
||||
}
|
||||
|
||||
const previews = [];
|
||||
/** Driven by scheduleRender(); detail + grid hover. */
|
||||
let renderRafId = 0;
|
||||
|
||||
function scheduleRender() {
|
||||
@@ -502,11 +515,28 @@ function startGridHover(previewState, card) {
|
||||
}
|
||||
|
||||
function createPreviewCard(shader) {
|
||||
const { id, name, author = "unknown", views = 0, likes = 0, code, thumbnailUrl } = shader;
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
author = "unknown",
|
||||
views = 0,
|
||||
likes = 0,
|
||||
code,
|
||||
thumbnailUrl,
|
||||
userLiked = false,
|
||||
} = shader;
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
card.dataset.search = `${name} ${author}`.toLowerCase();
|
||||
|
||||
const authorRaw = String(author || "").trim();
|
||||
const showAuthor = authorRaw && authorRaw.toLowerCase() !== "unknown";
|
||||
const authorHtml = showAuthor
|
||||
? `<span class="card-meta-author">${escHtml(authorRaw)}</span>`
|
||||
: "";
|
||||
const likedClass = userLiked ? " is-liked" : "";
|
||||
const likedAria = userLiked ? "true" : "false";
|
||||
|
||||
const visualInner = thumbnailUrl
|
||||
? `<img class="card-thumb" src="${thumbnailUrl}" alt="" loading="lazy" decoding="async" />`
|
||||
: `<div class="card-thumb card-thumb--placeholder">暂无缩略图<br /><small>正在尝试自动生成…</small></div>`;
|
||||
@@ -514,12 +544,21 @@ function createPreviewCard(shader) {
|
||||
card.dataset.shaderId = id;
|
||||
card.innerHTML = `
|
||||
<div class="card-head">
|
||||
<strong>${name}</strong>
|
||||
<strong>${escHtml(name)}</strong>
|
||||
</div>
|
||||
<div class="card-stage">${visualInner}</div>
|
||||
<div class="card-meta">
|
||||
<span>by ${author}</span>
|
||||
<span>views ${views} | likes ${likes}</span>
|
||||
<div class="card-meta-left">${authorHtml}</div>
|
||||
<div class="card-meta-stats">
|
||||
<span class="stat-pill" title="浏览量">
|
||||
<svg class="stat-ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span class="js-views">${views}</span>
|
||||
</span>
|
||||
<button type="button" class="stat-pill stat-like${likedClass}" data-like-id="${id}" aria-label="点赞" aria-pressed="${likedAria}" ${userLiked ? "disabled" : ""}>
|
||||
<svg class="stat-ico stat-ico-heart" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${userLiked ? 'fill="currentColor" stroke="none"' : 'fill="none" stroke="currentColor" stroke-width="2"'} aria-hidden="true"><path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
||||
<span class="js-likes">${likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -528,9 +567,49 @@ function createPreviewCard(shader) {
|
||||
name,
|
||||
code,
|
||||
card,
|
||||
views,
|
||||
likes,
|
||||
userLiked,
|
||||
};
|
||||
|
||||
card.addEventListener("click", () => openDetail(state));
|
||||
const likeBtn = card.querySelector(".stat-like");
|
||||
if (likeBtn) {
|
||||
likeBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (likeBtn.classList.contains("is-liked") || likeBtn.disabled) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}/like`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ visitorId: getVisitorId() }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
const likesEl = likeBtn.querySelector(".js-likes");
|
||||
if (likesEl && d.likes != null) likesEl.textContent = String(d.likes);
|
||||
state.likes = d.likes;
|
||||
if (d.liked) {
|
||||
likeBtn.classList.add("is-liked");
|
||||
likeBtn.setAttribute("aria-pressed", "true");
|
||||
likeBtn.disabled = true;
|
||||
const hv = likeBtn.querySelector(".stat-ico-heart");
|
||||
if (hv) {
|
||||
hv.setAttribute("fill", "currentColor");
|
||||
hv.removeAttribute("stroke");
|
||||
hv.removeAttribute("stroke-width");
|
||||
}
|
||||
state.userLiked = true;
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".stat-like")) return;
|
||||
openDetail(state);
|
||||
});
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
if (detailViewEl.classList.contains("active")) return;
|
||||
@@ -578,6 +657,7 @@ function closeDetail() {
|
||||
gl.deleteBuffer(vbo);
|
||||
channelTextures.forEach((tex) => gl.deleteTexture(tex));
|
||||
detailRuntime = null;
|
||||
if (detailFpsEl) detailFpsEl.textContent = "—";
|
||||
}
|
||||
|
||||
function openDetail(previewState) {
|
||||
@@ -645,6 +725,17 @@ function openDetail(previewState) {
|
||||
detailViewEl.classList.add("active");
|
||||
detailViewEl.setAttribute("aria-hidden", "false");
|
||||
scheduleRender();
|
||||
|
||||
void fetch(`${API_BASE}/${encodeURIComponent(previewState.id)}/view`, { method: "POST" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (!d || d.views == null) return;
|
||||
const c = gridEl.querySelector(`[data-shader-id="${CSS.escape(previewState.id)}"]`);
|
||||
const vEl = c && c.querySelector(".js-views");
|
||||
if (vEl) vEl.textContent = String(d.views);
|
||||
previewState.views = d.views;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
@@ -657,7 +748,10 @@ function applySearch() {
|
||||
|
||||
async function loadShaders() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?t=${Date.now()}`, { cache: "no-store" });
|
||||
const res = await fetch(`${API_BASE}?t=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
headers: { "X-Visitor-Id": getVisitorId() },
|
||||
});
|
||||
if (!res.ok) throw new Error("加载失败");
|
||||
const shaders = await res.json();
|
||||
const signature = JSON.stringify(
|
||||
@@ -685,7 +779,7 @@ function syncStats() {
|
||||
statsEl.textContent = `预览数量: ${previews.length} | 全局时间: ${elapsed.toFixed(2)}s | 状态: ${
|
||||
paused ? "已暂停" : "运行中"
|
||||
}`;
|
||||
shaderCountEl.textContent = `${previews.length} shaders`;
|
||||
shaderCountEl.textContent = String(previews.length);
|
||||
}
|
||||
|
||||
function renderAll(ts) {
|
||||
@@ -699,7 +793,7 @@ function renderAll(ts) {
|
||||
lastSec = Math.floor(elapsed);
|
||||
fps = fpsCounter;
|
||||
fpsCounter = 0;
|
||||
globalFpsEl.textContent = `FPS: ${fps}`;
|
||||
globalFpsEl.textContent = String(fps);
|
||||
} else {
|
||||
fpsCounter += 1;
|
||||
}
|
||||
@@ -785,7 +879,7 @@ function renderAll(ts) {
|
||||
detailLastSec = Math.floor(elapsed);
|
||||
detailFps = detailFpsCounter;
|
||||
detailFpsCounter = 0;
|
||||
detailFpsEl.textContent = `FPS: ${detailFps}`;
|
||||
detailFpsEl.textContent = String(detailFps);
|
||||
} else {
|
||||
detailFpsCounter += 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user