- ToolPanels:背景/滤镜/文字/导出与模板四组可折叠分组,选中图片/文字自动展开相关组 - SourcePanel:拖放与文件选择默认作为新图层加入(addImage toBack:false 并选中), 「作为背景铺满」改为面板里的显式勾选项,不再是一切的默认行为;多张可一次全加 - 上传 object URL 改为会话内保留、卸载时统一释放(撤销与模板重载要按 src 重新取图) - 预设几何与 Unsplash 保持原「铺背景」语义,不受勾选项影响 - 新增 pickImageFiles 纯函数与 src/sources/selfcheck.ts Node 侧自检
598 lines
17 KiB
Vue
598 lines
17 KiB
Vue
<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>
|