Merge branch 't4-sources' into integration

This commit is contained in:
2026-09-04 16:40:47 +08:00
5 changed files with 1213 additions and 22 deletions
+529 -8
View File
@@ -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>
+471
View File
@@ -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
View File
@@ -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";
+145
View File
@@ -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 ?? "",
};
}
+39
View File
@@ -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;
});
}