refactor: 背景图层接进主线
- 预设底纹统一走 sources/geometry 的 GEOMETRY_PRESETS(16 条), BackgroundMenu 不再自带一份 12 条的定义。 - 删掉 SourcePanel.vue(内容已搬进 BackgroundMenu),连带清掉 unsplash 里 只为它保留的 legacy 函数面和 index 的对应出口。 - PhotoProviderError 从 unsplash.ts 挪到独立的 errors.ts。原来放在叶子模块里 是为了绕开和 providers.ts 的循环依赖,独立文件同样绕得开而且不别扭。 - 背景改成和普通图层一样可点选可拖动:用户对背景的核心需求是「调整/缩放/裁剪 一下」,不可点选就没法在画布上取景;取消选中点画板外的灰底即可。 - useLayers 把背景钉在最底:它拖不上去,别的图层也压不到它下面。
This commit is contained in:
@@ -1,597 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 【T4→T10 — 背景来源面板】
|
||||
*
|
||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。
|
||||
*
|
||||
* - 上传:文件选择 + 全窗口拖放。T10 起拖放/多选的默认行为是**新建一个图层**
|
||||
* (`addImage` 的 `toBack: false`:加在最上层并选中,多张一次全加);
|
||||
* 面板里的「作为背景铺满」勾上才铺满画布当背景(旧行为)。
|
||||
* object URL 全部存活到卸载时统一释放——撤销与模板重载还要重新取图。
|
||||
* - 预设几何 / Unsplash:保持 T4 验收过的「铺背景」语义,不受上面那个勾选影响。
|
||||
*
|
||||
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
import { commit } from "@/editor/useHistory";
|
||||
import { fromUpload, pickImageFiles, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「点一下」来源(几何缩略图 / Unsplash 结果)共用的一条路径:素材 → 进画布 → 记署名。
|
||||
* `toBack: true` = 作为背景铺满(沉到最底层);false = 作为图层加在最上层并选中。
|
||||
* 成功记一步历史,撤销能整步撤掉。
|
||||
*/
|
||||
async function place(asset: BackgroundAsset, label: string, toBack: boolean) {
|
||||
if (busy.value) return;
|
||||
if (asset.kind === "upload") liveUploadUrls.push(asset.src); // 撤销/模板重载要重新取图
|
||||
busyLabel.value = label;
|
||||
busy.value = true;
|
||||
notice.value = "";
|
||||
try {
|
||||
await addImage(asset.src, { toBack });
|
||||
lastAttribution.value = asset.attribution ?? null;
|
||||
commit();
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "图片没能装进画布");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 本地上传 */
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
/**
|
||||
* 勾上「作为背景铺满」时,上传/拖放的图片沉到最底层当背景(旧行为);
|
||||
* 默认不勾——拖进来是**新建一个图层**,加在最上层并选中它。
|
||||
*/
|
||||
const asBackground = ref(false);
|
||||
/**
|
||||
* 本会话所有上传产生的 object URL。不提前 revoke:撤销与模板重载时
|
||||
* fabric 还要按 src 重新取图,提前放掉会让历史/模板里的图片变空白。
|
||||
*/
|
||||
const liveUploadUrls: string[] = [];
|
||||
|
||||
function onFilePick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = ""; // 允许重复选同一个文件
|
||||
if (files.length > 0) void addFiles(files, "选中的文件里没有图片");
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传/拖放共用的入口:这一批图片**一次全加进画布**(多选或一次拖多张都支持)。
|
||||
* 每张先读成本地 object URL 再进画布;全部处理完记一步历史(一次拖放 = 一步撤销)。
|
||||
* 默认每张都作为新图层加在最上层并选中(最新一张在最上);
|
||||
* 勾了「作为背景铺满」则每张沉到最底层当背景。
|
||||
*/
|
||||
async function addFiles(files: readonly File[], emptyMessage: string) {
|
||||
if (busy.value) return;
|
||||
const images = pickImageFiles(files);
|
||||
if (images.length === 0) {
|
||||
notice.value = emptyMessage;
|
||||
return;
|
||||
}
|
||||
busyLabel.value = images.length > 1 ? `载入 ${images.length} 张图片…` : "读取图片…";
|
||||
busy.value = true;
|
||||
notice.value = "";
|
||||
let ok = 0;
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
for (const file of images) {
|
||||
try {
|
||||
const asset = await fromUpload(file);
|
||||
await addImage(asset.src, { toBack: asBackground.value });
|
||||
liveUploadUrls.push(asset.src);
|
||||
lastAttribution.value = asset.attribution ?? null;
|
||||
ok++;
|
||||
} catch {
|
||||
failed.push(file.name || "未命名文件");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
if (ok > 0) commit();
|
||||
if (failed.length > 0) {
|
||||
notice.value =
|
||||
ok === 0
|
||||
? `这 ${failed.length} 张都加不进去(图片读不出来)`
|
||||
: `${ok} 张已加入,${failed.length} 张失败:${failed.slice(0, 2).join("、")}${failed.length > 2 ? "…" : ""}`;
|
||||
} else if (ok > 1) {
|
||||
notice.value = `已加进 ${ok} 张图片(最新一张在最上层,顺序可在左栏图层里调)`;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全窗口拖放:文件拖进窗口任意处,默认作为新图层加入(或按勾选铺底)。
|
||||
面板常驻挂载(ToolPanels 里 v-show 控制显隐),所以在面板上挂 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 files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
void addFiles(files, "拖进来的文件里没有图片");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 预设几何 */
|
||||
|
||||
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, "生成几何底纹…", true);
|
||||
} 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 图…", true);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 署名复制 */
|
||||
|
||||
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?.();
|
||||
// 本会话所有上传的 object URL 一次性释放。
|
||||
liveUploadUrls.forEach(releaseObjectUrl);
|
||||
liveUploadUrls.length = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="source-panel">
|
||||
<p v-if="notice" class="notice">{{ notice }}</p>
|
||||
|
||||
<!-- ============ 本地上传 ============ -->
|
||||
<h3 class="block-title">本地上传</h3>
|
||||
<div class="field">
|
||||
<label class="mode">
|
||||
<input v-model="asBackground" type="checkbox" :disabled="busy" />
|
||||
<span>作为背景铺满(沉到最底层)</span>
|
||||
</label>
|
||||
<button class="wide" :disabled="busy" @click="fileInput?.click()">选择图片文件…</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<p class="hint">
|
||||
也可以直接把图片拖进窗口任意位置。默认作为<strong>新图层</strong>加在最上层并选中,
|
||||
多张可一次全加;勾上「作为背景铺满」则铺满画布当背景。
|
||||
</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">
|
||||
{{ asBackground ? "松开鼠标:作为背景铺满画布" : "松开鼠标:把图片添加为新图层" }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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);
|
||||
}
|
||||
.block-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 「作为背景铺满」开关 */
|
||||
.mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mode input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -14,10 +14,10 @@ import { useEditor } from "@/editor/useEditor";
|
||||
import { useBackground } from "@/editor/useBackground";
|
||||
import { fromUpload } from "@/sources/upload";
|
||||
import {
|
||||
createGeometryAsset,
|
||||
deriveGeometryParams,
|
||||
GEOMETRY_PRESETS,
|
||||
createPresetAsset,
|
||||
derivePresetParams,
|
||||
geometryToDataUrl,
|
||||
type GeometryKind,
|
||||
} from "@/sources/geometry";
|
||||
import { PHOTO_PROVIDERS, PhotoProviderError } from "@/sources/providers";
|
||||
import type { Attribution, BackgroundAsset, PhotoProvider, PhotoProviderId, StockPhoto } from "@/types";
|
||||
@@ -70,52 +70,29 @@ function onSolidInput(e: Event) {
|
||||
|
||||
/* ---------------------------------------------------------------- 预设 */
|
||||
|
||||
interface GeometryPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: GeometryKind;
|
||||
/**
|
||||
* 固定种子字符串。它的 FNV hash 决定色相(见 geometry.ts 的 deriveGeometryParams),
|
||||
* 下面这些词是特意挑来把 12 个预设分散在色环上的;label 只是展示名,不必和 seed 同字。
|
||||
*/
|
||||
seed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 12 个预设底纹 = 4 种图案 × 3 档色相,风格参照 color4bg.com 那类「适合当封面
|
||||
* 底图的抽象渐变 / 网格 / 光斑」。配色已经由 deriveGeometryParams 压暗降饱和,
|
||||
* 保证封面上压白字可读——这是 t4-sources 就定下的约束,别改回去。
|
||||
* 预设底纹的**唯一真源是 `@/sources/geometry` 的 `GEOMETRY_PRESETS`**(16 条,
|
||||
* 8 种图案 × 2 档色相),不在这个组件里另立一份。
|
||||
*
|
||||
* 配色由 `derivePresetParams` 压暗降饱和,保证封面上压白字可读——这是 t4-sources
|
||||
* 就定下的约束,`sources/selfcheck.ts` 里有逐像素对比度断言看着,别改回去。
|
||||
*/
|
||||
const PRESETS: GeometryPreset[] = [
|
||||
{ id: "p-stripe-red", label: "绯红斜纹", kind: "stripes", seed: "琥珀" },
|
||||
{ id: "p-stripe-cyan", label: "雾青斜纹", kind: "stripes", seed: "雾蓝" },
|
||||
{ id: "p-stripe-violet", label: "墨紫斜纹", kind: "stripes", seed: "石墨" },
|
||||
{ id: "p-noise-olive", label: "橄榄噪点", kind: "noise", seed: "赭石" },
|
||||
{ id: "p-noise-moss", label: "苔绿噪点", kind: "noise", seed: "松绿" },
|
||||
{ id: "p-noise-rose", label: "玫红噪点", kind: "noise", seed: "极光" },
|
||||
{ id: "p-grid-navy", label: "藏青网格", kind: "grid", seed: "藏青" },
|
||||
{ id: "p-grid-indigo", label: "靛蓝网格", kind: "grid", seed: "靛蓝" },
|
||||
{ id: "p-grid-smoke", label: "烟紫网格", kind: "grid", seed: "胭脂" },
|
||||
{ id: "p-arc-pine", label: "松青光弧", kind: "arcs", seed: "群青" },
|
||||
{ id: "p-arc-dusk", label: "暮蓝光弧", kind: "arcs", seed: "焦糖" },
|
||||
{ id: "p-arc-magenta", label: "品红光弧", kind: "arcs", seed: "烟紫" },
|
||||
];
|
||||
|
||||
const THUMB_W = 160;
|
||||
const THUMB_H = 100;
|
||||
|
||||
/** 预设 + 它的缩略图(就是那个渐变本身,确定性生成,computed 缓存不重复渲)。 */
|
||||
/** 预设 + 它的缩略图(就是那张底纹本身,确定性生成,computed 缓存不重复渲)。 */
|
||||
const presetThumbs = computed(() =>
|
||||
PRESETS.map((p) => ({
|
||||
...p,
|
||||
thumb: geometryToDataUrl(deriveGeometryParams(p.seed, p.kind, THUMB_W, THUMB_H)),
|
||||
GEOMETRY_PRESETS.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
thumb: geometryToDataUrl(derivePresetParams(p.id, THUMB_W, THUMB_H)),
|
||||
})),
|
||||
);
|
||||
|
||||
function applyPreset(p: GeometryPreset) {
|
||||
function applyPreset(presetId: string) {
|
||||
if (busy.value) return;
|
||||
try {
|
||||
const asset = createGeometryAsset(p.seed, p.kind, size.value.width, size.value.height);
|
||||
const asset = createPresetAsset(presetId, size.value.width, size.value.height);
|
||||
void applyAsset(asset, "生成预设底纹…");
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "预设底纹生成失败");
|
||||
@@ -264,7 +241,7 @@ async function copyAttribution(a: Attribution) {
|
||||
class="preset"
|
||||
:disabled="busy"
|
||||
:title="p.label"
|
||||
@click="applyPreset(p)"
|
||||
@click="applyPreset(p.id)"
|
||||
>
|
||||
<img :src="p.thumb" :alt="p.label" />
|
||||
<span class="preset-label">{{ p.label }}</span>
|
||||
|
||||
@@ -15,10 +15,11 @@ import { capImageSize } from "./imageLimits";
|
||||
* 关键实现约束(都是踩出来的,别再踩):
|
||||
* - `current()` 每次现找,不要缓存引用——`loadFromJSON` 之后画布上是新对象,
|
||||
* 缓存的引用会指向一个已经不在画布上的旧对象。
|
||||
* - `selectable: false` 要放在**最后**设:`canvas.add()` 和 `sendObjectToBack()`
|
||||
* 都会触发 `object:added`,而 useEditor 那个监听器会再跑一次 `registerLayer()`
|
||||
* (`applyLocked(obj,false)` 会把 selectable 翻回 true),所以必须先 add、先
|
||||
* 置底,最后再关 selectable。
|
||||
* - **背景和普通图层一样可以点选、拖动、缩放。** 一开始做成了不可点选(防止点空白
|
||||
* 处误选),但用户对背景的核心需求就是「调整 / 缩放 / 裁剪一下」,不可点选就没法
|
||||
* 在画布上取景。要取消选中点画板外面的灰底就行——画布是无限工作区,画板外一大片
|
||||
* 都是空的。(顺带省掉一个坑:`selectable:false` 会被图层面板的解锁按钮
|
||||
* `applyLocked(obj,false)` 翻回 true,得靠「最后再设」这种顺序技巧才压得住。)
|
||||
* - 铺满用 `width * scaleX` 而不是 `getScaledWidth()`:后者会把默认 strokeWidth
|
||||
* 也算进去(见 common 的 Fabric 陷阱 3)。
|
||||
*/
|
||||
@@ -110,9 +111,6 @@ export function useBackground() {
|
||||
registerLayer(img);
|
||||
c.add(img);
|
||||
c.sendObjectToBack(img);
|
||||
// 最后再关 selectable:add/sendObjectToBack 的 object:added 会把 selectable
|
||||
// 翻回 true(见文件头注释)。背景在画布上不可点选,但图层面板里仍能选中。
|
||||
img.selectable = false;
|
||||
c.requestRenderAll();
|
||||
bumpRevision();
|
||||
commit();
|
||||
@@ -137,7 +135,6 @@ export function useBackground() {
|
||||
registerLayer(rect);
|
||||
c.add(rect);
|
||||
c.sendObjectToBack(rect);
|
||||
rect.selectable = false;
|
||||
c.requestRenderAll();
|
||||
bumpRevision();
|
||||
commit();
|
||||
|
||||
@@ -99,14 +99,20 @@ export function deleteLayer(id: string) {
|
||||
/**
|
||||
* 把 `id` 挪到图层面板的第 `toRow` 行(0 = 最上层)。拖拽排序用。
|
||||
* 内部换算成 fabric 的绘制序号:`绘制序号 = 对象总数 - 1 - 行号`。
|
||||
*
|
||||
* 背景图层**钉在最底**:它自己拖不上去,别的图层也压不到它下面。
|
||||
* 「背景始终置于底部」是这个图层存在的意义,靠约束保证比靠用户自觉可靠。
|
||||
*/
|
||||
export function moveLayerToRow(id: string, toRow: number) {
|
||||
const c = getCanvas();
|
||||
const objs = c.getObjects();
|
||||
const obj = find(id);
|
||||
if (!obj) return;
|
||||
const target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow));
|
||||
c.moveObjectTo(obj, target);
|
||||
if (obj.isBackground) return;
|
||||
let target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow));
|
||||
// 画布上有背景时,绘制序号 0 是它的位置,别的对象最低只能到 1。
|
||||
if (target === 0 && objs.some((o) => o.isBackground)) target = 1;
|
||||
c.moveObjectTo(obj, Math.min(target, objs.length - 1));
|
||||
c.requestRenderAll();
|
||||
bumpRevision();
|
||||
commit();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话,不让路径崩掉。
|
||||
*
|
||||
* 单独一个文件而不是塞进 `providers.ts`:两家 provider 都要抛它,而
|
||||
* `providers.ts` 又要 import 两家来组注册表,定义在那里就成了循环依赖——
|
||||
* ESM 的环里谁先被加载谁踩 TDZ,症状是整页白屏。放在这个不依赖任何兄弟模块的
|
||||
* 叶子里,环从根上不成立。
|
||||
*/
|
||||
export class PhotoProviderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PhotoProviderError";
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
* - pexels.ts / unsplash.ts 图库搜索,都实现 `PhotoProvider` 接口,
|
||||
* Pexels 在前(`PHOTO_PROVIDERS` 见 providers.ts)
|
||||
*
|
||||
* T4 时代的 legacy 函数面(`searchUnsplash` / `toAsset` 等)保留到旧
|
||||
* SourcePanel 被 t13 清掉为止;新界面一律走 `PHOTO_PROVIDERS`。
|
||||
* 图库一律走 `PHOTO_PROVIDERS`,不要直接 import 某一家。
|
||||
*/
|
||||
|
||||
export { fromUpload, releaseObjectUrl, pickImageFiles } from "./upload";
|
||||
@@ -36,14 +35,9 @@ export type {
|
||||
} from "./geometry";
|
||||
|
||||
export {
|
||||
unsplashStatus,
|
||||
searchUnsplash,
|
||||
trackDownload,
|
||||
toAsset,
|
||||
parseUnsplashResponse,
|
||||
unsplashProvider,
|
||||
} from "./unsplash";
|
||||
export type { UnsplashStatus, UnsplashPhoto } from "./unsplash";
|
||||
|
||||
export { parsePexelsResponse, pexelsProvider } from "./pexels";
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import type {
|
||||
PhotoProviderStatus,
|
||||
StockPhoto,
|
||||
} from "../types";
|
||||
import { PhotoProviderError } from "./unsplash";
|
||||
import { PhotoProviderError } from "./errors";
|
||||
|
||||
/**
|
||||
* Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。
|
||||
*
|
||||
* - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: <key>`,
|
||||
* 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。
|
||||
* - 共享错误类 `PhotoProviderError` 定义在 unsplash.ts 那个不依赖兄弟模块的叶子里,
|
||||
* - 共享错误类 `PhotoProviderError` 在 `errors.ts`(不依赖任何兄弟模块的叶子),
|
||||
* 从那里 import,避免和 providers.ts 的注册表互相引成环。
|
||||
* - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。
|
||||
* - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { unsplashProvider } from "./unsplash";
|
||||
|
||||
// 共享错误类定义在 unsplash.ts(依赖最少的叶子模块),这里 re-export 保持桩文件
|
||||
// 原本的出口路径;pexels.ts 也直接 import 它,避免 providers ↔ 各家模块引成环。
|
||||
export { PhotoProviderError } from "./unsplash";
|
||||
export { PhotoProviderError } from "./errors";
|
||||
|
||||
/**
|
||||
* 图库注册表。
|
||||
|
||||
@@ -21,9 +21,7 @@ import { pickImageFiles } from "./upload";
|
||||
import { parsePexelsResponse, pexelsProvider } from "./pexels";
|
||||
import {
|
||||
parseUnsplashResponse,
|
||||
searchUnsplash,
|
||||
unsplashProvider,
|
||||
unsplashStatus,
|
||||
} from "./unsplash";
|
||||
import { PHOTO_PROVIDERS, PhotoProviderError, providerById } from "./providers";
|
||||
import {
|
||||
@@ -264,13 +262,6 @@ for (const p of PHOTO_PROVIDERS) {
|
||||
`${p.id}.search() 没配 key 时抛 PhotoProviderError,而不是别的`,
|
||||
);
|
||||
}
|
||||
assert.equal(unsplashStatus(), "missing", "legacy unsplashStatus 同步读同一个 key");
|
||||
await assert.rejects(
|
||||
searchUnsplash("forest"),
|
||||
(err) => err instanceof PhotoProviderError,
|
||||
"legacy searchUnsplash 也走同一错误类(老面板显示 message 用)",
|
||||
);
|
||||
|
||||
console.log("✓ 注册表与缺失态:Pexels 在前,没 key 时两家 status=missing、search 抛 PhotoProviderError");
|
||||
|
||||
/* ---------------- 4. T14:几何预设的数量与白字对比度 ---------------- */
|
||||
|
||||
+4
-107
@@ -2,16 +2,13 @@ import type {
|
||||
Attribution,
|
||||
BackgroundAsset,
|
||||
PhotoProvider,
|
||||
PhotoProviderStatus,
|
||||
StockPhoto,
|
||||
} from "../types";
|
||||
import { PhotoProviderError } from "./errors";
|
||||
|
||||
/**
|
||||
* Unsplash 搜索。T14 起归一到 `PhotoProvider` 接口(界面只认接口,不写
|
||||
* `if (provider === "unsplash")`),同时**保留 T4 时代的 legacy 函数面**
|
||||
* (`searchUnsplash` / `unsplashStatus` / `toAsset` / `trackDownload` /
|
||||
* `UnsplashPhoto`):基座里的旧 SourcePanel 还直接 import 它们,删了会砸掉
|
||||
* 这一轮的 typecheck;等 t13 把旧面板清掉后这些只是没人用的历史出口。
|
||||
* Unsplash 搜索,归一到 `PhotoProvider` 接口——界面只认接口,不写
|
||||
* `if (provider === "unsplash")`。
|
||||
*
|
||||
* - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`。
|
||||
* - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示,
|
||||
@@ -21,84 +18,6 @@ import type {
|
||||
* - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ 共享错误 */
|
||||
|
||||
/**
|
||||
* 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话。
|
||||
*
|
||||
* 故意定义在这个**不依赖任何兄弟模块**的叶子里:`pexels.ts` 从这 import、
|
||||
* `providers.ts` 从这里 re-export。若定义在 providers.ts 而让两家 provider
|
||||
* 反过去 import 它,就会和 providers 的注册表互相 import 成环——ESM 环里
|
||||
* 谁先被加载谁就踩 TDZ(旧 SourcePanel 只 import unsplash 时会先踩到)。
|
||||
*/
|
||||
export class PhotoProviderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PhotoProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ legacy 类型 */
|
||||
|
||||
export type UnsplashStatus = PhotoProviderStatus;
|
||||
|
||||
/** 一张搜索结果,带网格展示与装进画布所需的全部信息(T4 时代的形状)。 */
|
||||
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";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ legacy 导出(T4 SourcePanel 还在用) */
|
||||
|
||||
/** legacy:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */
|
||||
export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
|
||||
const attribution: Attribution = {
|
||||
text: `Photo by ${photo.authorName} on Unsplash`,
|
||||
url: photo.pageUrl,
|
||||
};
|
||||
return {
|
||||
kind: "stock",
|
||||
src: photo.regularUrl,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
attribution,
|
||||
};
|
||||
}
|
||||
|
||||
/** legacy:单页搜索(不带分页参数),结果仍是 UnsplashPhoto。新界面走 provider。 */
|
||||
export async function searchUnsplash(query: string): Promise<UnsplashPhoto[]> {
|
||||
const raws = await fetchRawUnsplash(query, 1, 12);
|
||||
return raws.map(toLegacyPhoto);
|
||||
}
|
||||
|
||||
/** legacy:用户选定一张图时触发 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 统计失败不影响用户操作 */
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ PhotoProvider */
|
||||
|
||||
/** 纯函数解析:官方响应样本 → 归一化 StockPhoto[]。selfcheck 在 Node 里喂样本断言。 */
|
||||
@@ -114,7 +33,7 @@ export const unsplashProvider: PhotoProvider = {
|
||||
label: "Unsplash",
|
||||
// 申请 key 的页面,status 为 missing 时界面把它显示出来。
|
||||
keyUrl: "https://unsplash.com/oauth/applications",
|
||||
status: unsplashStatus,
|
||||
status: () => (readKey() ? "ready" : "missing"),
|
||||
search: async (query, page) => stocksFromRaws(await fetchRawUnsplash(query, page, 15)),
|
||||
toAsset: toStockAsset,
|
||||
/** 条款义务:用户选定一张图时打一次 download 统计。只有 Unsplash 有。 */
|
||||
@@ -180,10 +99,6 @@ function authorNameOf(raw: UnsplashRawPhoto): string {
|
||||
return raw.user?.name?.trim() || "Unsplash 用户";
|
||||
}
|
||||
|
||||
function pageUrlOf(raw: UnsplashRawPhoto): string {
|
||||
return raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* raw → StockPhoto(provider / parseUnsplashResponse 共用)。
|
||||
* 空字段太多、连缩略图和原图页都没有的结果没法用,返回 null 让上层跳过。
|
||||
@@ -212,24 +127,6 @@ function toStockPhoto(raw: UnsplashRawPhoto): StockPhoto | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** raw → legacy UnsplashPhoto(保持 T4 语义原样,别和 StockPhoto 解析互相牵连)。 */
|
||||
function toLegacyPhoto(raw: UnsplashRawPhoto): UnsplashPhoto {
|
||||
const authorName = authorNameOf(raw);
|
||||
const pageUrl = pageUrlOf(raw);
|
||||
return {
|
||||
id: raw.id ?? "",
|
||||
width: raw.width ?? 0,
|
||||
height: raw.height ?? 0,
|
||||
description: raw.description ?? null,
|
||||
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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** raw 数组 → StockPhoto[](provider.search 用;parse 系列只认整棵官方响应)。 */
|
||||
function stocksFromRaws(raws: readonly UnsplashRawPhoto[]): StockPhoto[] {
|
||||
const out: StockPhoto[] = [];
|
||||
|
||||
Reference in New Issue
Block a user