Merge branch 't4-sources' into integration
This commit is contained in:
@@ -1,22 +1,543 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 【T4 待实现 — 背景来源面板】
|
* 【T4 — 背景来源面板】
|
||||||
*
|
*
|
||||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。选中后调
|
* 三个来源:本地上传、预设几何、Unsplash 搜索。任一来源选中的素材都调
|
||||||
* `useEditor().addImage(asset.src)` 装进画布。
|
* `useEditor().addImage(asset.src)` 装进画布(按 cover 方式铺满并选中)。
|
||||||
*
|
*
|
||||||
* Unsplash 的署名**不画进图里**:把 `asset.attribution.text` 显示在面板上,
|
* - 上传:文件选择 + 全窗口拖放。object URL 换图时释放旧的。
|
||||||
* 旁边一个复制按钮,用户自己贴进文章。
|
* - 预设几何:种子输入(建议填标题)+ 四张实时缩略图。同种子必同图。
|
||||||
|
* - Unsplash:没配 key 只显示提示,另外两个来源照常可用;
|
||||||
|
* 搜索结果点一张装 `regular` 尺寸;选定即触发 download 统计(条款义务);
|
||||||
|
* 署名不画进图,显示在面板上给复制按钮。
|
||||||
|
*
|
||||||
|
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
|
||||||
*/
|
*/
|
||||||
|
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
|
||||||
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
import { fromUpload, releaseObjectUrl } from "@/sources/upload";
|
||||||
|
import {
|
||||||
|
GEOMETRY_KINDS,
|
||||||
|
deriveGeometryParams,
|
||||||
|
geometryToDataUrl,
|
||||||
|
createGeometryAsset,
|
||||||
|
type GeometryKind,
|
||||||
|
} from "@/sources/geometry";
|
||||||
|
import {
|
||||||
|
unsplashStatus,
|
||||||
|
searchUnsplash,
|
||||||
|
trackDownload,
|
||||||
|
toAsset,
|
||||||
|
type UnsplashPhoto,
|
||||||
|
} from "@/sources/unsplash";
|
||||||
|
import type { Attribution, BackgroundAsset } from "@/types";
|
||||||
|
|
||||||
|
const { addImage, size } = useEditor();
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 通用 */
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
const busyLabel = ref("");
|
||||||
|
const notice = ref(""); // 非阻塞提示/错误,下一次动作清掉
|
||||||
|
const lastAttribution = ref<Attribution | null>(null);
|
||||||
|
|
||||||
|
function errorText(err: unknown, fallback: string): string {
|
||||||
|
return err instanceof Error && err.message ? err.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三个来源共用的一条路径:素材 → 替换旧上传 URL → 进画布 → 记署名。 */
|
||||||
|
async function place(asset: BackgroundAsset, label: string) {
|
||||||
|
if (busy.value) return;
|
||||||
|
busyLabel.value = label;
|
||||||
|
busy.value = true;
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
// 换背景 = 替换:上一张本地上传的 object URL 不再需要,释放掉。
|
||||||
|
if (lastUploadUrl.value && lastUploadUrl.value !== asset.src) {
|
||||||
|
releaseObjectUrl(lastUploadUrl.value);
|
||||||
|
}
|
||||||
|
lastUploadUrl.value = asset.kind === "upload" ? asset.src : "";
|
||||||
|
await addImage(asset.src);
|
||||||
|
lastAttribution.value = asset.attribution ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
notice.value = errorText(err, "图片没能装进画布");
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 本地上传 */
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const lastUploadUrl = ref("");
|
||||||
|
|
||||||
|
function onFilePick(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = ""; // 允许重复选同一个文件
|
||||||
|
if (file) void applyFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFile(file: File) {
|
||||||
|
if (busy.value) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
notice.value = "只能放图片文件";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busyLabel.value = "读取图片…";
|
||||||
|
busy.value = true;
|
||||||
|
notice.value = "";
|
||||||
|
let asset: BackgroundAsset;
|
||||||
|
try {
|
||||||
|
asset = await fromUpload(file);
|
||||||
|
} catch (err) {
|
||||||
|
notice.value = errorText(err, "图片读不出来");
|
||||||
|
busy.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy.value = false;
|
||||||
|
await place(asset, "载入图片…");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全窗口拖放:文件拖进窗口任意处即作为背景载入。
|
||||||
|
面板常驻显示,所以在面板上挂 window 级监听即可覆盖画布区。 */
|
||||||
|
const dragging = ref(false);
|
||||||
|
let dragDepth = 0;
|
||||||
|
|
||||||
|
function hasFiles(dt: DataTransfer | null): boolean {
|
||||||
|
return !!dt && dt.types.includes("Files");
|
||||||
|
}
|
||||||
|
/** 落到原生 file input 上的拖放交给 input 自己处理,别抢。 */
|
||||||
|
function onNativeInput(e: Event): boolean {
|
||||||
|
const el = e.target as Element | null;
|
||||||
|
return !!el && !!el.closest?.('input[type="file"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent) {
|
||||||
|
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth++;
|
||||||
|
dragging.value = true;
|
||||||
|
}
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||||
|
e.preventDefault(); // 不放行就没有 drop 事件
|
||||||
|
}
|
||||||
|
function onDragLeave(e: DragEvent) {
|
||||||
|
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) dragging.value = false;
|
||||||
|
}
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragDepth = 0;
|
||||||
|
dragging.value = false;
|
||||||
|
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const file = Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
||||||
|
f.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
if (!file) {
|
||||||
|
notice.value = "拖进来的不是图片";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void applyFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 预设几何 */
|
||||||
|
|
||||||
|
const seedText = ref("");
|
||||||
|
const THUMB_W = 320;
|
||||||
|
const thumbH = () =>
|
||||||
|
Math.max(72, Math.round((THUMB_W * size.value.height) / size.value.width));
|
||||||
|
const thumbs = reactive<Record<GeometryKind, string>>({
|
||||||
|
stripes: "",
|
||||||
|
noise: "",
|
||||||
|
grid: "",
|
||||||
|
arcs: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
let thumbTimer: number | undefined;
|
||||||
|
function refreshThumbs() {
|
||||||
|
window.clearTimeout(thumbTimer);
|
||||||
|
thumbTimer = window.setTimeout(() => {
|
||||||
|
for (const k of GEOMETRY_KINDS) {
|
||||||
|
const params = deriveGeometryParams(seedText.value, k.id, THUMB_W, thumbH());
|
||||||
|
thumbs[k.id] = geometryToDataUrl(params);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomizeSeed() {
|
||||||
|
const pool = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < 6; i++) s += pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
seedText.value = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGeometry(kind: GeometryKind) {
|
||||||
|
if (busy.value) return;
|
||||||
|
try {
|
||||||
|
const asset = createGeometryAsset(
|
||||||
|
seedText.value,
|
||||||
|
kind,
|
||||||
|
size.value.width,
|
||||||
|
size.value.height,
|
||||||
|
);
|
||||||
|
void place(asset, "生成几何底纹…");
|
||||||
|
} catch (err) {
|
||||||
|
notice.value = errorText(err, "几何底纹生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Unsplash */
|
||||||
|
|
||||||
|
const unsplashState = ref(unsplashStatus());
|
||||||
|
const query = ref("");
|
||||||
|
const results = ref<UnsplashPhoto[]>([]);
|
||||||
|
const searchedFor = ref("");
|
||||||
|
const searching = ref(false);
|
||||||
|
const searchError = ref("");
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const q = query.value.trim();
|
||||||
|
if (!q || searching.value || busy.value) return;
|
||||||
|
searchError.value = "";
|
||||||
|
searchedFor.value = "";
|
||||||
|
searching.value = true;
|
||||||
|
try {
|
||||||
|
const hits = await searchUnsplash(q);
|
||||||
|
results.value = hits;
|
||||||
|
searchedFor.value = q;
|
||||||
|
} catch (err) {
|
||||||
|
searchError.value = errorText(err, "搜索失败");
|
||||||
|
results.value = [];
|
||||||
|
} finally {
|
||||||
|
searching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickPhoto(photo: UnsplashPhoto) {
|
||||||
|
if (busy.value) return;
|
||||||
|
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
|
||||||
|
await place(toAsset(photo), "载入 Unsplash 图…");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 署名复制 */
|
||||||
|
|
||||||
|
const copied = ref(false);
|
||||||
|
async function copyAttribution(a: Attribution) {
|
||||||
|
const text = a.text;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
// 非安全上下文(http 局域网)兜底
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
copied.value = true;
|
||||||
|
window.setTimeout(() => (copied.value = false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 生命周期 */
|
||||||
|
|
||||||
|
let stopWatch: WatchStopHandle | undefined;
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("dragenter", onDragEnter);
|
||||||
|
window.addEventListener("dragover", onDragOver);
|
||||||
|
window.addEventListener("dragleave", onDragLeave);
|
||||||
|
window.addEventListener("drop", onDrop);
|
||||||
|
refreshThumbs();
|
||||||
|
stopWatch = watch(
|
||||||
|
[() => seedText.value, () => size.value.width, () => size.value.height],
|
||||||
|
refreshThumbs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("dragenter", onDragEnter);
|
||||||
|
window.removeEventListener("dragover", onDragOver);
|
||||||
|
window.removeEventListener("dragleave", onDragLeave);
|
||||||
|
window.removeEventListener("drop", onDrop);
|
||||||
|
window.clearTimeout(thumbTimer);
|
||||||
|
stopWatch?.();
|
||||||
|
if (lastUploadUrl.value) releaseObjectUrl(lastUploadUrl.value);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="todo">T4 待实现:上传 / 预设几何 / Unsplash</p>
|
<div class="source-panel">
|
||||||
|
<p v-if="notice" class="notice">{{ notice }}</p>
|
||||||
|
|
||||||
|
<!-- ============ 本地上传 ============ -->
|
||||||
|
<h3 class="block-title">本地上传</h3>
|
||||||
|
<div class="field">
|
||||||
|
<button class="wide" :disabled="busy" @click="fileInput?.click()">选择图片文件…</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFilePick"
|
||||||
|
/>
|
||||||
|
<p class="hint">或直接把图片拖进窗口任意位置,自动铺满画布。换图时旧地址自动释放。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 预设几何 ============ -->
|
||||||
|
<h3 class="block-title">预设几何</h3>
|
||||||
|
<div class="field">
|
||||||
|
<div class="row">
|
||||||
|
<input
|
||||||
|
v-model="seedText"
|
||||||
|
placeholder="配色种子,建议填标题"
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
<button class="mini" title="随机一组配色(仍是确定性的:同种子同图)" :disabled="busy" @click="randomizeSeed">
|
||||||
|
随机
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="thumbs">
|
||||||
|
<button
|
||||||
|
v-for="k in GEOMETRY_KINDS"
|
||||||
|
:key="k.id"
|
||||||
|
class="thumb"
|
||||||
|
:style="{ aspectRatio: `${THUMB_W} / ${thumbH()}` }"
|
||||||
|
:disabled="busy"
|
||||||
|
:title="k.label"
|
||||||
|
@click="addGeometry(k.id)"
|
||||||
|
>
|
||||||
|
<img :src="thumbs[k.id]" :alt="k.label" />
|
||||||
|
<span class="thumb-label">{{ k.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Unsplash 搜索 ============ -->
|
||||||
|
<h3 class="block-title">Unsplash 搜索</h3>
|
||||||
|
<template v-if="unsplashState === 'ready'">
|
||||||
|
<div class="field row">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
placeholder="搜什么关键词"
|
||||||
|
:disabled="searching || busy"
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
/>
|
||||||
|
<button :disabled="searching || busy || !query.trim()" @click="doSearch">搜索</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="searching" class="hint">搜索中…</p>
|
||||||
|
<p v-else-if="searchError" class="hint">{{ searchError }}</p>
|
||||||
|
<p v-else-if="searchedFor" class="hint">
|
||||||
|
「{{ searchedFor }}」{{ results.length }} 张,点一张装进画布
|
||||||
|
</p>
|
||||||
|
<div v-if="results.length" class="results">
|
||||||
|
<button
|
||||||
|
v-for="ph in results"
|
||||||
|
:key="ph.id"
|
||||||
|
class="shot"
|
||||||
|
:disabled="busy"
|
||||||
|
:title="ph.altDescription ?? ph.description ?? ph.authorName"
|
||||||
|
@click="pickPhoto(ph)"
|
||||||
|
>
|
||||||
|
<img :src="ph.thumbUrl" :alt="ph.altDescription ?? ph.authorName" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="hint">
|
||||||
|
未配置 Unsplash Key —— 在仓库根目录 <code>.env.local</code> 里写
|
||||||
|
<code>VITE_UNSPLASH_KEY=…</code> 后重启 dev。本地上传与预设几何不受影响。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ============ 署名(不画进图) ============ -->
|
||||||
|
<div v-if="lastAttribution" class="field attrib">
|
||||||
|
<label>署名 · 复制进文章正文</label>
|
||||||
|
<div class="attrib-row row">
|
||||||
|
<code class="attrib-text">{{ lastAttribution.text }}</code>
|
||||||
|
<button class="mini" @click="copyAttribution(lastAttribution)">
|
||||||
|
{{ copied ? "已复制" : "复制" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="lastAttribution.url"
|
||||||
|
class="attrib-link"
|
||||||
|
:href="lastAttribution.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>打开原图页 ↗</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="busy" class="busy">{{ busyLabel }}</p>
|
||||||
|
|
||||||
|
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="dragging" class="drop-veil" aria-hidden="true">
|
||||||
|
<div class="drop-hint">松开鼠标:把这张图作为封面背景</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.todo {
|
.source-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
margin: 0;
|
}
|
||||||
|
.block-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
background: var(--bg3);
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 几何缩略图:2×2 */
|
||||||
|
.thumbs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.thumb-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unsplash 结果网格:3 列 */
|
||||||
|
.results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.shot {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.shot img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 署名块 */
|
||||||
|
.attrib {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.attrib-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.attrib-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.attrib-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全窗口拖放提示(fixed 盖住整个 app,不拦鼠标) */
|
||||||
|
.drop-veil {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.drop-hint {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
color: var(--fg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 8px 32px rgb(0 0 0 / 0.45);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import type { BackgroundAsset } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设几何底纹。
|
||||||
|
*
|
||||||
|
* 动机:用户有 13 篇文章至今没有封面,其中「单元测试」系列 7 篇全缺——
|
||||||
|
* 大概率就是「这篇没有能当封面的图」。几何底纹兜住这种情况:
|
||||||
|
* 没有合适的照片也能生成一张能压白字的背景。
|
||||||
|
*
|
||||||
|
* 结构:`deriveGeometryParams` + `renderGeometry` 都是**不依赖 DOM 的纯函数**,
|
||||||
|
* 直接往 RGBA 像素缓冲里写;DOM 只出现在最薄的出口层
|
||||||
|
* (`geometryToDataUrl` / `createGeometryAsset`,浏览器里才用得到)。
|
||||||
|
* 因此同一份几何逻辑能在 Node 侧直接验确定性:同一种子跑两次像素完全一致。
|
||||||
|
*
|
||||||
|
* 确定性靠两个约定:
|
||||||
|
* - 随机序列来自种子字符串的 FNV hash + mulberry32,全程不用 `Math.random`;
|
||||||
|
* - 同一套参数只产同一份缓冲,`toDataURL` 只是它的无损包装。
|
||||||
|
*
|
||||||
|
* 种子建议填标题:同一系列标题前缀相同 → 落进同一个 45° 色区,
|
||||||
|
* 系列内不同篇再在色区内按全文 hash 微差几度,列表里自然成组又不至于一模一样。
|
||||||
|
* 种子留空时每种底纹各有一个专属默认色相,四张缩略图一眼分得开。
|
||||||
|
*
|
||||||
|
* 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度——
|
||||||
|
* 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1,
|
||||||
|
* 饱和度压到低饱和、偏中性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GeometryKind = "stripes" | "noise" | "grid" | "arcs";
|
||||||
|
|
||||||
|
export const GEOMETRY_KINDS = [
|
||||||
|
{ id: "stripes", label: "斜向条纹" },
|
||||||
|
{ id: "noise", label: "噪点渐变" },
|
||||||
|
{ id: "grid", label: "网格线" },
|
||||||
|
{ id: "arcs", label: "同心弧" },
|
||||||
|
] as const satisfies readonly { id: GeometryKind; label: string }[];
|
||||||
|
|
||||||
|
export type RGB = readonly [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一张底纹的全部决定因素。只含派生结果,不含输入——
|
||||||
|
* 拿到这个结构就能渲染,也就能在 Node 里对它断言确定性。
|
||||||
|
*/
|
||||||
|
export interface GeometryParams {
|
||||||
|
kind: GeometryKind;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** 种子全文的 FNV hash(u32),像素级噪声用它做盐。 */
|
||||||
|
seed32: number;
|
||||||
|
/** 由种子确定性产出的 [0,1) 随机流,各底纹各取所需。 */
|
||||||
|
u: readonly number[];
|
||||||
|
/** 底色,最深,白色文字主要压在这上面。 */
|
||||||
|
base: RGB;
|
||||||
|
/** 主强调色,中明度低饱和。 */
|
||||||
|
toneA: RGB;
|
||||||
|
/** 副强调色,比 toneA 沉一点,用来做交替/层次。 */
|
||||||
|
toneB: RGB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 留空种子时每种底纹的默认色相:分散在色环上,缩略图可一眼区分。 */
|
||||||
|
const EMPTY_HUE: Record<GeometryKind, number> = {
|
||||||
|
stripes: 215, // 蓝
|
||||||
|
noise: 25, // 琥珀
|
||||||
|
grid: 265, // 紫
|
||||||
|
arcs: 350, // 玫红
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 由种子文本派生一组确定性参数。纯函数。 */
|
||||||
|
export function deriveGeometryParams(
|
||||||
|
seedText: string,
|
||||||
|
kind: GeometryKind,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): GeometryParams {
|
||||||
|
const text = (seedText ?? "").trim();
|
||||||
|
const empty = text.length === 0;
|
||||||
|
// 空种子也必须有确定性:以 kind 本身作 hashKey,四种底纹天然不同源。
|
||||||
|
const hashKey = empty ? `geometry:${kind}` : text;
|
||||||
|
const hFull = hashText(hashKey);
|
||||||
|
const hPrefix = hashText(hashKey.slice(0, 4));
|
||||||
|
const rand = mulberry32(hFull);
|
||||||
|
const u: number[] = [];
|
||||||
|
for (let i = 0; i < 16; i++) u.push(rand());
|
||||||
|
|
||||||
|
// 色相:非空种子按「前 4 字符分桶 + 全文微调」,同系列相邻成组;
|
||||||
|
// 空种子直接取该 kind 的专属默认色相。
|
||||||
|
const hue = empty
|
||||||
|
? EMPTY_HUE[kind]
|
||||||
|
: ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100);
|
||||||
|
const off = 46 + u[1] * 54; // 主色到强调色的色相偏移(46~100°,类比色相,不花)
|
||||||
|
|
||||||
|
// 低饱和、偏中性(底要被压白字,高饱和会抢注意力)
|
||||||
|
const sBase = Math.min(0.18, 0.08 + u[0] * 0.1);
|
||||||
|
const sA = Math.min(0.3, 0.18 + u[3] * 0.12);
|
||||||
|
const sB = Math.min(0.24, 0.14 + u[4] * 0.1);
|
||||||
|
|
||||||
|
// 线性空间限亮度,保证对白字的 WCAG 对比度:
|
||||||
|
// 底色 ≤ 0.10 相对亮度 → 对 #fff ≈ 7:1;
|
||||||
|
// 副强调 ≤ 0.135 → ≈ 5.7:1;主强调 ≤ 0.165 → ≈ 4.9:1(最亮也不破 4.5)。
|
||||||
|
const base = clampLuminance(hsl2rgb(hue, sBase, 0.21 + u[2] * 0.05), 0.1);
|
||||||
|
const toneA = clampLuminance(hsl2rgb(hue + off, sA, 0.38 + u[5] * 0.06), 0.165);
|
||||||
|
const toneB = clampLuminance(hsl2rgb(hue + off * 0.5, sB, 0.29 + u[6] * 0.05), 0.135);
|
||||||
|
|
||||||
|
return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染成不透明 RGBA 像素缓冲。纯函数,不碰 DOM。 */
|
||||||
|
export function renderGeometry(p: GeometryParams): {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: Uint8ClampedArray;
|
||||||
|
} {
|
||||||
|
const data = new Uint8ClampedArray(p.width * p.height * 4);
|
||||||
|
switch (p.kind) {
|
||||||
|
case "stripes":
|
||||||
|
renderStripes(p, data);
|
||||||
|
break;
|
||||||
|
case "noise":
|
||||||
|
renderNoiseGradient(p, data);
|
||||||
|
break;
|
||||||
|
case "grid":
|
||||||
|
renderGrid(p, data);
|
||||||
|
break;
|
||||||
|
case "arcs":
|
||||||
|
renderArcs(p, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { width: p.width, height: p.height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 各底纹 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 斜向粗圆角条纹:沿对角方向的平行圆角粗条(胶囊),隔行交错配色铺满整幅。
|
||||||
|
* 旋转坐标系里 u 沿条纹方向、v 垂直;v 方向每 rowPitch 一根条(条+空隙),
|
||||||
|
* 每根条由多个圆头段(capsule)排成虚线状,两端是圆角。
|
||||||
|
*/
|
||||||
|
function renderStripes(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
const inv = Math.SQRT1_2;
|
||||||
|
const slope = u[8] < 0.5 ? 1 : -1; // 取两个斜向之一
|
||||||
|
const halfT = Math.max(5, w * 0.02); // 条宽的一半(粗)
|
||||||
|
const len = Math.max(halfT * 3.2, w * 0.16); // 每段圆头条长度
|
||||||
|
const period = len * 1.75; // 段长 + 段间空隙
|
||||||
|
const rowPitch = halfT * 4; // 条宽 + 行间空隙
|
||||||
|
const phase = u[9] * period;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const [bR, bG, bB] = p.base;
|
||||||
|
const [aR, aG, aB] = p.toneA;
|
||||||
|
const [tR, tG, tB] = p.toneB;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const yy = y - cy;
|
||||||
|
const sy = slope * yy;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const xx = x - cx;
|
||||||
|
// 投影到「沿条方向 t × 垂直方向 v」(都以画布中心为原点,两向都穿全幅)
|
||||||
|
const t = (xx + sy) * inv;
|
||||||
|
const v = (xx - sy) * inv;
|
||||||
|
const row = Math.round(v / rowPitch);
|
||||||
|
const vc = v - row * rowPitch; // 相对本行条中心线的垂直偏移
|
||||||
|
const tShift = t - phase;
|
||||||
|
const col = Math.floor(tShift / period);
|
||||||
|
const tt = tShift - col * period; // 本段位置 [0, period),条本体 [0, len]
|
||||||
|
const du = tt > len ? tt - len : 0;
|
||||||
|
const d = Math.sqrt(du * du + vc * vc);
|
||||||
|
const a = halfT + 0.5 - d; // capsule:圆头圆角,边缘 1px 抗锯齿
|
||||||
|
const useA = (row & 1) === 1;
|
||||||
|
let r = bR;
|
||||||
|
let g = bG;
|
||||||
|
let b = bB;
|
||||||
|
if (a >= 1) {
|
||||||
|
r = useA ? aR : tR;
|
||||||
|
g = useA ? aG : tG;
|
||||||
|
b = useA ? aB : tB;
|
||||||
|
} else if (a > 0) {
|
||||||
|
r = useA ? bR + (aR - bR) * a : bR + (tR - bR) * a;
|
||||||
|
g = useA ? bG + (aG - bG) * a : bG + (tG - bG) * a;
|
||||||
|
b = useA ? bB + (aB - bB) * a : bB + (tB - bB) * a;
|
||||||
|
}
|
||||||
|
out[i++] = r | 0;
|
||||||
|
out[i++] = g | 0;
|
||||||
|
out[i++] = b | 0;
|
||||||
|
out[i++] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 噪点渐变:对角渐变(toneB → base,明度从中到低)+ 每像素确定性细噪点。 */
|
||||||
|
function renderNoiseGradient(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
const sx = u[8] < 0.5 ? 1 : -1;
|
||||||
|
const sy = u[9] < 0.5 ? 1 : -1;
|
||||||
|
const amp = 3 + u[10] * 4; // 噪点幅度
|
||||||
|
// 对角投影 t 的取值区间(四个角的极值)
|
||||||
|
let lo = 0;
|
||||||
|
let hi = 0;
|
||||||
|
for (const [cx, cy] of [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[0, h],
|
||||||
|
[w, h],
|
||||||
|
] as const) {
|
||||||
|
const v = sx * (cx / w) + sy * (cy / h);
|
||||||
|
if (v < lo) lo = v;
|
||||||
|
if (v > hi) hi = v;
|
||||||
|
}
|
||||||
|
const span = hi - lo || 1;
|
||||||
|
const [fR, fG, fB] = p.toneB; // 浅端
|
||||||
|
const [tR, tG, tB] = p.base; // 深端
|
||||||
|
const seed = p.seed32;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const yy = sy * (y / h);
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const t = (sx * (x / w) + yy - lo) / span;
|
||||||
|
const n = (hash2d(x, y, seed) - 0.5) * 2 * amp;
|
||||||
|
out[i++] = clamp255(fR + (tR - fR) * t + n);
|
||||||
|
out[i++] = clamp255(fG + (tG - fG) * t + n);
|
||||||
|
out[i++] = clamp255(fB + (tB - fB) * t + n);
|
||||||
|
out[i++] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网格线:细网格,每隔几格一条主格线更亮一点。 */
|
||||||
|
function renderGrid(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
const g = Math.max(16, w * 0.048); // 格距
|
||||||
|
const offX = (u[8] - 0.5) * g;
|
||||||
|
const offY = (u[9] - 0.5) * g;
|
||||||
|
const hwMinor = Math.max(1, w * 0.0017); // 次格线半宽
|
||||||
|
const hwMajor = hwMinor * 1.7;
|
||||||
|
const majorEvery = 5;
|
||||||
|
const [mA, mG, mB] = p.toneA; // 主格线
|
||||||
|
const [nR, nG, nB] = p.toneB; // 次格线
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const yv = y + 0.5 - offY;
|
||||||
|
const ky = Math.round(yv / g);
|
||||||
|
const dy = Math.abs(yv - ky * g);
|
||||||
|
const majorY = mod(ky, majorEvery) === 0;
|
||||||
|
const aHorz = lineCoverage(dy, majorY ? hwMajor : hwMinor);
|
||||||
|
const hR = majorY ? mA : nR;
|
||||||
|
const hG = majorY ? mG : nG;
|
||||||
|
const hB = majorY ? mB : nB;
|
||||||
|
const rowBase = y * w * 4;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const xv = x + 0.5 - offX;
|
||||||
|
const kx = Math.round(xv / g);
|
||||||
|
const dx = Math.abs(xv - kx * g);
|
||||||
|
const majorX = mod(kx, majorEvery) === 0;
|
||||||
|
const aVert = lineCoverage(dx, majorX ? hwMajor : hwMinor);
|
||||||
|
// 先叠竖线再叠横线,交叉点像真实网格那样被后画的盖住
|
||||||
|
let r = aVert > 0 ? (majorX ? mA : nR) * aVert : 0;
|
||||||
|
let gg = aVert > 0 ? (majorX ? mG : nG) * aVert : 0;
|
||||||
|
let b = aVert > 0 ? (majorX ? mB : nB) * aVert : 0;
|
||||||
|
if (aHorz > 0) {
|
||||||
|
r += hR * aHorz;
|
||||||
|
gg += hG * aHorz;
|
||||||
|
b += hB * aHorz;
|
||||||
|
}
|
||||||
|
const idx = rowBase + x * 4;
|
||||||
|
out[idx] = (p.base[0] + r) | 0;
|
||||||
|
out[idx + 1] = (p.base[1] + gg) | 0;
|
||||||
|
out[idx + 2] = (p.base[2] + b) | 0;
|
||||||
|
out[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mod(n: number, m: number): number {
|
||||||
|
return ((n % m) + m) % m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网格像素的线覆盖度:距线心半宽以内满覆盖,边缘 1px 抗锯齿。 */
|
||||||
|
function lineCoverage(dist: number, halfWidth: number): number {
|
||||||
|
const a = halfWidth + 0.5 - dist;
|
||||||
|
return a <= 0 ? 0 : a >= 1 ? 1 : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同心弧:圆心悬在画布上方(横向随种子偏移),可见部分是从上方发散下来的
|
||||||
|
* 一圈圈圆弧;每圈随机留一道缺口,颜色交替,像声呐/涟漪。
|
||||||
|
*/
|
||||||
|
function renderArcs(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
const cx = w * (0.3 + u[8] * 0.4);
|
||||||
|
const cy = -h * (0.6 + u[9] * 0.6); // 圆心在画布上方
|
||||||
|
const pitch = Math.max(18, w * 0.058); // 环间距
|
||||||
|
const half = Math.max(4, pitch * 0.17); // 环带半宽
|
||||||
|
const twoHalf = half * 2;
|
||||||
|
const aa = Math.max(1, half * 0.5); // 环带边缘过渡宽
|
||||||
|
const angAA = 0.05; // 缺口边缘(弧度)过渡宽
|
||||||
|
const seed = p.seed32;
|
||||||
|
const [bR, bG, bB] = p.base;
|
||||||
|
const [aR, aG, aB] = p.toneA;
|
||||||
|
const [tR, tG, tB] = p.toneB;
|
||||||
|
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const dy = y - cy;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const dx = x - cx;
|
||||||
|
const r = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const k = Math.floor(r / pitch);
|
||||||
|
const pos = r - k * pitch; // 在环带内的位置 [0, pitch)
|
||||||
|
if (pos >= twoHalf) continue;
|
||||||
|
// 环带覆盖度:两侧边缘过渡
|
||||||
|
const cov = Math.min(pos / aa, (twoHalf - pos) / aa, 1);
|
||||||
|
if (cov <= 0) continue;
|
||||||
|
// 每圈的缺口(确定性)
|
||||||
|
const nc = Math.PI * (0.15 + hash2d(k, 7, seed) * 0.7);
|
||||||
|
const nw = Math.PI * (0.12 + hash2d(k, 13, seed) * 0.24);
|
||||||
|
const ang = Math.atan2(dy, dx); // 画布都在圆心下方,ang ∈ (0, π)
|
||||||
|
const lo = nc - nw / 2;
|
||||||
|
const hi = nc + nw / 2;
|
||||||
|
let angCov = 1;
|
||||||
|
if (ang > lo && ang < hi) angCov = 0;
|
||||||
|
else if (ang <= lo) angCov = Math.min((lo - ang) / angAA, 1);
|
||||||
|
else angCov = Math.min((ang - hi) / angAA, 1);
|
||||||
|
if (angCov <= 0) continue;
|
||||||
|
const a = cov * angCov;
|
||||||
|
const useA = (k & 1) === 1;
|
||||||
|
const idx = (y * w + x) * 4;
|
||||||
|
const lR = useA ? aR : tR;
|
||||||
|
const lG = useA ? aG : tG;
|
||||||
|
const lB = useA ? aB : tB;
|
||||||
|
out[idx] = (bR + (lR - bR) * a) | 0;
|
||||||
|
out[idx + 1] = (bG + (lG - bG) * a) | 0;
|
||||||
|
out[idx + 2] = (bB + (lB - bB) * a) | 0;
|
||||||
|
out[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 工具 */
|
||||||
|
|
||||||
|
function fill(out: Uint8ClampedArray, w: number, h: number, color: RGB): void {
|
||||||
|
const [r, g, b] = color;
|
||||||
|
let i = 0;
|
||||||
|
for (let n = 0; n < w * h; n++) {
|
||||||
|
out[i++] = r;
|
||||||
|
out[i++] = g;
|
||||||
|
out[i++] = b;
|
||||||
|
out[i++] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp255(v: number): number {
|
||||||
|
return v < 0 ? 0 : v > 255 ? 255 : v | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32 位。确定性字符串 hash。 */
|
||||||
|
function hashText(s: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mulberry32:32 位种子 → 确定性 [0,1) 序列。 */
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (x, y, seed) → 确定性 [0,1),网格噪声与缺口位置用它。 */
|
||||||
|
function hash2d(x: number, y: number, seed: number): number {
|
||||||
|
let h =
|
||||||
|
(Math.imul(x | 0, 0x9e3779b1) ^ Math.imul(y | 0, 0x85ebca77) ^ (seed >>> 0)) >>> 0;
|
||||||
|
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) >>> 0;
|
||||||
|
h = Math.imul(h ^ (h >>> 12), 0x297a2d39) >>> 0;
|
||||||
|
h = (h ^ (h >>> 15)) >>> 0;
|
||||||
|
return h / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HSL → RGB(0-255)。s、l 均 [0,1]。 */
|
||||||
|
function hsl2rgb(hueDeg: number, s: number, l: number): RGB {
|
||||||
|
const h = ((hueDeg % 360) + 360) % 360;
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (h < 60) {
|
||||||
|
r = c; g = x;
|
||||||
|
} else if (h < 120) {
|
||||||
|
r = x; g = c;
|
||||||
|
} else if (h < 180) {
|
||||||
|
g = c; b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
g = x; b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x; b = c;
|
||||||
|
} else {
|
||||||
|
r = c; b = x;
|
||||||
|
}
|
||||||
|
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sRGB 通道值(0-255) → 线性光 (0-1)。 */
|
||||||
|
function linearize(v: number): number {
|
||||||
|
const c = v / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 线性光 → sRGB 通道值。 */
|
||||||
|
function toSrgb(lin: number): number {
|
||||||
|
const c = lin <= 0.0031308 ? lin * 12.92 : 1.055 * Math.pow(lin, 1 / 2.4) - 0.055;
|
||||||
|
const v = Math.round((c < 0 ? 0 : c > 1 ? 1 : c) * 255);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WCAG 相对亮度(0-1)。 */
|
||||||
|
function luminance(rgb: RGB): number {
|
||||||
|
return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变),
|
||||||
|
* 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。
|
||||||
|
*/
|
||||||
|
function clampLuminance(rgb: RGB, maxLum: number): RGB {
|
||||||
|
const l = luminance(rgb);
|
||||||
|
if (l <= maxLum) return rgb;
|
||||||
|
const k = maxLum / l;
|
||||||
|
return [
|
||||||
|
toSrgb(linearize(rgb[0]) * k),
|
||||||
|
toSrgb(linearize(rgb[1]) * k),
|
||||||
|
toSrgb(linearize(rgb[2]) * k),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- DOM 出口层 */
|
||||||
|
|
||||||
|
/** 像素缓冲 → dataURL。唯一用到 DOM 的层,浏览器里才调用。 */
|
||||||
|
export function geometryToDataUrl(p: GeometryParams): string {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = p.width;
|
||||||
|
canvas.height = p.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("拿不到 canvas 2d context");
|
||||||
|
const { data } = renderGeometry(p);
|
||||||
|
ctx.putImageData(new ImageData(new Uint8ClampedArray(data), p.width, p.height), 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一张能直接交给 useEditor().addImage() 的背景图。 */
|
||||||
|
export function createGeometryAsset(
|
||||||
|
seedText: string,
|
||||||
|
kind: GeometryKind,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): BackgroundAsset {
|
||||||
|
const params = deriveGeometryParams(seedText, kind, width, height);
|
||||||
|
return { kind: "geometry", src: geometryToDataUrl(params), width, height };
|
||||||
|
}
|
||||||
+29
-14
@@ -1,18 +1,33 @@
|
|||||||
import type { BackgroundAsset } from "@/types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T4 待实现 — 背景来源】
|
* 背景来源汇总出口。
|
||||||
*
|
*
|
||||||
* 三个来源各写一个模块,都返回 BackgroundAsset:
|
* 三个来源各写一个模块,都返回/产出 `BackgroundAsset`(形状见 `@/types`):
|
||||||
* - upload.ts 本地文件 -> object URL
|
* - upload.ts 本地文件 → object URL
|
||||||
* - geometry.ts 预设几何底纹,程序生成(画到离屏 canvas 再 toDataURL)
|
* - geometry.ts 预设几何底纹:纯函数派生+渲染,DOM 只出现在薄薄一层
|
||||||
* - unsplash.ts Unsplash 搜索。Access Key 从 import.meta.env.VITE_UNSPLASH_KEY 读,
|
* - unsplash.ts Unsplash 搜索。Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读
|
||||||
* 放 .env.local(已 gitignore)。条款强制署名,返回时填 attribution。
|
*
|
||||||
|
* SourcePanel 按需从子模块直接 import;这里只是把表面聚成一个稳定出口,
|
||||||
|
* 供面板以外的地方统一消费。
|
||||||
*/
|
*/
|
||||||
export async function fromUpload(_file: File): Promise<BackgroundAsset> {
|
|
||||||
throw new Error("T4 未实现:fromUpload");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchUnsplash(_query: string): Promise<BackgroundAsset[]> {
|
export { fromUpload, releaseObjectUrl } from "./upload";
|
||||||
throw new Error("T4 未实现:searchUnsplash");
|
|
||||||
}
|
export {
|
||||||
|
deriveGeometryParams,
|
||||||
|
renderGeometry,
|
||||||
|
geometryToDataUrl,
|
||||||
|
createGeometryAsset,
|
||||||
|
GEOMETRY_KINDS,
|
||||||
|
} from "./geometry";
|
||||||
|
export type { GeometryParams, GeometryKind, RGB } from "./geometry";
|
||||||
|
|
||||||
|
export {
|
||||||
|
unsplashStatus,
|
||||||
|
searchUnsplash,
|
||||||
|
trackDownload,
|
||||||
|
toAsset,
|
||||||
|
UnsplashError,
|
||||||
|
} from "./unsplash";
|
||||||
|
export type { UnsplashStatus, UnsplashPhoto } from "./unsplash";
|
||||||
|
|
||||||
|
export type { BackgroundAsset, Attribution } from "../types";
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { Attribution, BackgroundAsset } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsplash 搜索。
|
||||||
|
*
|
||||||
|
* - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`(已 gitignore)。
|
||||||
|
* - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示,
|
||||||
|
* 上传与预设几何不受影响。所有对外函数都先查 `unsplashStatus()`。
|
||||||
|
* - 署名按用户要求**不画进图里**:`toAsset()` 把 attribution 填进
|
||||||
|
* `BackgroundAsset.attribution`,由面板显示成可复制文本,用户贴进文章正文。
|
||||||
|
* - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UnsplashStatus = "missing" | "ready";
|
||||||
|
|
||||||
|
/** 网络/参数层面的可预期错误,面板据此显示一句话,不让整条路径崩掉。 */
|
||||||
|
export class UnsplashError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "UnsplashError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一张搜索结果,带网格展示与装进画布所需的全部信息。 */
|
||||||
|
export interface UnsplashPhoto {
|
||||||
|
id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
description: string | null;
|
||||||
|
altDescription: string | null;
|
||||||
|
authorName: string;
|
||||||
|
thumbUrl: string;
|
||||||
|
/** 装进画布用的尺寸(regular,约 1080px)。 */
|
||||||
|
regularUrl: string;
|
||||||
|
/** 原图页(带 utm_source,署名链接用)。 */
|
||||||
|
pageUrl: string;
|
||||||
|
/** API 要求的 download_location,选定该图时触发。 */
|
||||||
|
downloadLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前是否配了 key。env 在 Vite 里必然有;Node/Bun/构建产物里可能没有,一律当缺失。 */
|
||||||
|
export function unsplashStatus(): UnsplashStatus {
|
||||||
|
return readKey() ? "ready" : "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面板能直接用:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */
|
||||||
|
export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
|
||||||
|
const attribution: Attribution = {
|
||||||
|
text: `Photo by ${photo.authorName} on Unsplash`,
|
||||||
|
url: photo.pageUrl,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: "unsplash",
|
||||||
|
src: photo.regularUrl,
|
||||||
|
width: photo.width,
|
||||||
|
height: photo.height,
|
||||||
|
attribution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUnsplash(query: string): Promise<UnsplashPhoto[]> {
|
||||||
|
const key = readKey();
|
||||||
|
if (!key) throw new UnsplashError("未配置 Unsplash Access Key(VITE_UNSPLASH_KEY)");
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
|
||||||
|
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&per_page=12&client_id=${encodeURIComponent(key)}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { "Accept-Version": "v1" } });
|
||||||
|
} catch {
|
||||||
|
throw new UnsplashError("连不上 Unsplash(网络或跨域限制)");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) throw new UnsplashError("Unsplash Access Key 无效,检查 .env.local");
|
||||||
|
if (res.status === 403 || res.status === 429)
|
||||||
|
throw new UnsplashError("Unsplash 请求被限流,等一会儿再试");
|
||||||
|
throw new UnsplashError(`Unsplash 返回 ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as UnsplashSearchResponse;
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
throw new UnsplashError(data.errors[0]);
|
||||||
|
}
|
||||||
|
return (data.results ?? []).map(toPhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户选定一张图时调用:触发 download 统计,条款义务。失败静默,不影响使用。 */
|
||||||
|
export function trackDownload(photo: UnsplashPhoto): void {
|
||||||
|
const key = readKey();
|
||||||
|
if (!key || !photo.downloadLocation) return;
|
||||||
|
const sep = photo.downloadLocation.includes("?") ? "&" : "?";
|
||||||
|
fetch(`${photo.downloadLocation}${sep}client_id=${encodeURIComponent(key)}`).catch(() => {
|
||||||
|
/* download 统计失败不影响用户操作 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 内部 */
|
||||||
|
|
||||||
|
function readKey(): string {
|
||||||
|
// import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。
|
||||||
|
const meta = import.meta as { env?: { VITE_UNSPLASH_KEY?: unknown } };
|
||||||
|
const raw = meta.env?.VITE_UNSPLASH_KEY;
|
||||||
|
return typeof raw === "string" ? raw.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnsplashSearchResponse = {
|
||||||
|
results?: UnsplashRawPhoto[];
|
||||||
|
errors?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnsplashRawPhoto = {
|
||||||
|
id: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
alt_description?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
user?: { name?: string };
|
||||||
|
urls?: {
|
||||||
|
thumb?: string;
|
||||||
|
small?: string;
|
||||||
|
regular?: string;
|
||||||
|
raw?: string;
|
||||||
|
};
|
||||||
|
links?: { html?: string; download_location?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const UTM = "utm_source=cover-studio&utm_medium=referral";
|
||||||
|
|
||||||
|
function toPhoto(raw: UnsplashRawPhoto): UnsplashPhoto {
|
||||||
|
const authorName = raw.user?.name?.trim() || "Unsplash 用户";
|
||||||
|
const pageUrl = raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`;
|
||||||
|
const description = raw.description ?? null;
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
width: raw.width ?? 0,
|
||||||
|
height: raw.height ?? 0,
|
||||||
|
description,
|
||||||
|
altDescription: raw.alt_description ?? null,
|
||||||
|
authorName,
|
||||||
|
thumbUrl: raw.urls?.small || raw.urls?.thumb || raw.urls?.regular || "",
|
||||||
|
regularUrl: raw.urls?.regular || raw.urls?.raw || "",
|
||||||
|
pageUrl,
|
||||||
|
downloadLocation: raw.links?.download_location ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BackgroundAsset } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地上传:本地文件 → object URL。
|
||||||
|
*
|
||||||
|
* object URL 的生命周期由调用方(SourcePanel)管理:换新背景时对旧的
|
||||||
|
* `releaseObjectUrl`,组件卸载时兜底释放一次,避免每次换图都漏一个 blob。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 把本地图片文件变成 BackgroundAsset。非图片/解不出尺寸会抛错并释放 URL。 */
|
||||||
|
export async function fromUpload(file: File): Promise<BackgroundAsset> {
|
||||||
|
const src = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const size = await readImageSize(src);
|
||||||
|
return { kind: "upload", src, width: size.width, height: size.height };
|
||||||
|
} catch (err) {
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
throw err instanceof Error ? err : new Error("不是可识别的图片文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有 blob: 前缀的 src 才可能是我们创建的 object URL,按需释放。 */
|
||||||
|
export function releaseObjectUrl(src: string): void {
|
||||||
|
if (src.startsWith("blob:")) URL.revokeObjectURL(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageSize(src: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const width = img.naturalWidth;
|
||||||
|
const height = img.naturalHeight;
|
||||||
|
if (width > 0 && height > 0) resolve({ width, height });
|
||||||
|
else reject(new Error("图片尺寸读不出来"));
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error("图片解码失败"));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user