Files
cover-studio/src/components/panels/SourcePanel.vue
T
im0o c9803827fd T10: 工具面板收进右栏折叠分组,拖放图片改为新增图层
- ToolPanels:背景/滤镜/文字/导出与模板四组可折叠分组,选中图片/文字自动展开相关组
- SourcePanel:拖放与文件选择默认作为新图层加入(addImage toBack:false 并选中),
  「作为背景铺满」改为面板里的显式勾选项,不再是一切的默认行为;多张可一次全加
- 上传 object URL 改为会话内保留、卸载时统一释放(撤销与模板重载要按 src 重新取图)
- 预设几何与 Unsplash 保持原「铺背景」语义,不受勾选项影响
- 新增 pickImageFiles 纯函数与 src/sources/selfcheck.ts Node 侧自检
2026-09-05 02:34:22 +08:00

598 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>