Merge branch 't13-background'
This commit is contained in:
@@ -1,7 +1,557 @@
|
||||
<script setup lang="ts">
|
||||
/** 【占位】BackgroundMenu 的内容由对应开发线实现。见任务书。 */
|
||||
/**
|
||||
* 【T13 — 顶栏「背景」下拉面板】
|
||||
*
|
||||
* 这是 `Dropdown` 的 panel 插槽内容(T11 在 TopBar 里包 Dropdown),所以根元素
|
||||
* 直接就是面板内容,不在这里自己套 Dropdown。行样式用 style.css 里的全局
|
||||
* `.menu-*` 类(作用域样式够不到插槽内容)。
|
||||
*
|
||||
* 四段:纯色 / 预设 / 上传 / 图库。核心语义是**换背景 = 替换那一个专用背景图层**,
|
||||
* 不是再加一层——这里所有入口都走 `useBackground().setBackground/setSolid`。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
import { useBackground } from "@/editor/useBackground";
|
||||
import { fromUpload } from "@/sources/upload";
|
||||
import {
|
||||
createGeometryAsset,
|
||||
deriveGeometryParams,
|
||||
geometryToDataUrl,
|
||||
type GeometryKind,
|
||||
} from "@/sources/geometry";
|
||||
import { PHOTO_PROVIDERS, PhotoProviderError } from "@/sources/providers";
|
||||
import type { Attribution, BackgroundAsset, PhotoProvider, PhotoProviderId, StockPhoto } from "@/types";
|
||||
import { IconChevronDown, IconGeometry, IconSearch, IconUpload } from "@/ui/icons";
|
||||
|
||||
const { size } = useEditor();
|
||||
const { setBackground, setSolid } = useBackground();
|
||||
|
||||
/* ---------------------------------------------------------------- 通用 */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 一条素材 → 那一个背景图层。成功记一步历史(setBackground 里 commit)。 */
|
||||
async function applyAsset(asset: BackgroundAsset, label: string) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
busyLabel.value = label;
|
||||
notice.value = "";
|
||||
try {
|
||||
await setBackground(asset);
|
||||
lastAttribution.value = asset.attribution ?? null;
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "图片没能装进画布");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 纯色 */
|
||||
|
||||
const solidColor = ref("#1b1b1f"); // 默认和画板底色一致
|
||||
function onSolidChange(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
solidColor.value = v;
|
||||
// 色板拖动只预览,松手(change)才真正替换并记一步历史,避免拖一帧记一步。
|
||||
setSolid(v);
|
||||
}
|
||||
|
||||
/** 色板拖动时同步 swatch 的受控值,不 commit。 */
|
||||
function onSolidInput(e: Event) {
|
||||
solidColor.value = (e.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 预设 */
|
||||
|
||||
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 就定下的约束,别改回去。
|
||||
*/
|
||||
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 缓存不重复渲)。 */
|
||||
const presetThumbs = computed(() =>
|
||||
PRESETS.map((p) => ({
|
||||
...p,
|
||||
thumb: geometryToDataUrl(deriveGeometryParams(p.seed, p.kind, THUMB_W, THUMB_H)),
|
||||
})),
|
||||
);
|
||||
|
||||
function applyPreset(p: GeometryPreset) {
|
||||
if (busy.value) return;
|
||||
try {
|
||||
const asset = createGeometryAsset(p.seed, p.kind, size.value.width, size.value.height);
|
||||
void applyAsset(asset, "生成预设底纹…");
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "预设底纹生成失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 上传 */
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
/**
|
||||
* 本会话上传产生的 object URL。**刻意不 revoke**:撤销 / 模板重载时 fabric 还要按
|
||||
* src 重新取图,提前放掉会让历史里的背景变空白。页面卸载时浏览器统一回收,
|
||||
* 泄漏只限本会话、受上传次数约束。这里不能用组件的 onBeforeUnmount 释放——
|
||||
* 下拉是 v-if 挂载的,每次开合都会卸载,那时放掉同样会废掉撤销。
|
||||
*/
|
||||
const liveUploadUrls: string[] = [];
|
||||
|
||||
async function onFilePick(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = ""; // 允许重复选同一个文件
|
||||
if (!file || busy.value) return;
|
||||
try {
|
||||
const asset = await fromUpload(file);
|
||||
liveUploadUrls.push(asset.src);
|
||||
await applyAsset(asset, "载入图片…");
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "图片没能装进画布");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 图库 */
|
||||
|
||||
const providers = PHOTO_PROVIDERS;
|
||||
const galleryOpen = ref(false);
|
||||
const activeProviderId = ref<PhotoProviderId | null>(providers[0]?.id ?? null);
|
||||
const activeProvider = computed<PhotoProvider | null>(
|
||||
() => providers.find((p) => p.id === activeProviderId.value) ?? providers[0] ?? null,
|
||||
);
|
||||
|
||||
const query = ref("");
|
||||
const results = ref<StockPhoto[]>([]);
|
||||
const page = ref(1);
|
||||
const searching = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const searchError = ref("");
|
||||
|
||||
function selectProvider(id: PhotoProviderId) {
|
||||
activeProviderId.value = id;
|
||||
query.value = "";
|
||||
results.value = [];
|
||||
page.value = 1;
|
||||
searchError.value = "";
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const provider = activeProvider.value;
|
||||
const q = query.value.trim();
|
||||
if (!provider || !q || searching.value || busy.value) return;
|
||||
searchError.value = "";
|
||||
searching.value = true;
|
||||
try {
|
||||
results.value = await provider.search(q, 1);
|
||||
page.value = 1;
|
||||
} catch (err) {
|
||||
// PhotoProviderError 的 message 是给用户看的一句话,直接显示。
|
||||
searchError.value = err instanceof PhotoProviderError ? err.message : errorText(err, "搜索失败");
|
||||
results.value = [];
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
const provider = activeProvider.value;
|
||||
const q = query.value.trim();
|
||||
if (!provider || !q || loadingMore.value || searching.value) return;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const next = await provider.search(q, page.value + 1);
|
||||
results.value = results.value.concat(next);
|
||||
page.value += 1;
|
||||
} catch (err) {
|
||||
searchError.value = err instanceof PhotoProviderError ? err.message : "加载更多失败";
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickPhoto(photo: StockPhoto) {
|
||||
const provider = activeProvider.value;
|
||||
if (!provider || busy.value) return;
|
||||
provider.notifyDownload?.(photo); // 条款义务,无条件调,不判断是哪家
|
||||
await applyAsset(provider.toAsset(photo), "载入图库图片…");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 署名复制 */
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-block">(BackgroundMenu 待实现)</div>
|
||||
<div class="bg-menu">
|
||||
<p v-if="notice" class="notice">{{ notice }}</p>
|
||||
|
||||
<!-- 1. 纯色 -->
|
||||
<label class="menu-row" title="纯色背景">
|
||||
<IconGeometry />
|
||||
<span>纯色</span>
|
||||
<input
|
||||
type="color"
|
||||
class="swatch"
|
||||
:value="solidColor"
|
||||
aria-label="纯色背景"
|
||||
@input="onSolidInput"
|
||||
@change="onSolidChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="menu-sep"></div>
|
||||
|
||||
<!-- 2. 预设(3 列卡片网格,缩略图就是那个渐变) -->
|
||||
<div class="menu-block">
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="p in presetThumbs"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:disabled="busy"
|
||||
:title="p.label"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
<img :src="p.thumb" :alt="p.label" />
|
||||
<span class="preset-label">{{ p.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-sep"></div>
|
||||
|
||||
<!-- 3. 上传 -->
|
||||
<button class="menu-row" :disabled="busy" @click="fileInput?.click()">
|
||||
<IconUpload />
|
||||
<span>从本地选择</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden-input" @change="onFilePick" />
|
||||
|
||||
<div class="menu-sep"></div>
|
||||
|
||||
<!-- 4. 图库 -->
|
||||
<button class="menu-row" :aria-expanded="galleryOpen" @click="galleryOpen = !galleryOpen">
|
||||
<IconSearch />
|
||||
<span>图库搜索</span>
|
||||
<IconChevronDown class="menu-aside" :class="{ rot: galleryOpen }" />
|
||||
</button>
|
||||
|
||||
<div v-if="galleryOpen" class="menu-block gallery">
|
||||
<p v-if="providers.length === 0" class="hint">图库还没接入,先用上面的上传 / 预设。</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
class="tab"
|
||||
:class="{ on: p.id === activeProviderId }"
|
||||
@click="selectProvider(p.id)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="activeProvider?.status() === 'missing'">
|
||||
<p class="hint">未配置 API key,暂时搜不了。</p>
|
||||
<a class="key-link" :href="activeProvider.keyUrl" target="_blank" rel="noreferrer">
|
||||
去申请 API key ↗
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="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="error">{{ searchError }}</p>
|
||||
<div v-if="results.length" class="results">
|
||||
<button
|
||||
v-for="ph in results"
|
||||
:key="ph.id"
|
||||
class="shot"
|
||||
:disabled="busy"
|
||||
:title="ph.alt ?? ph.authorName"
|
||||
@click="pickPhoto(ph)"
|
||||
>
|
||||
<img :src="ph.thumbUrl" :alt="ph.alt ?? ph.authorName" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="results.length" class="menu-row" :disabled="loadingMore" @click="loadMore">
|
||||
<span>{{ loadingMore ? "加载中…" : "加载更多" }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 署名:可复制的文本,不画进图(用户明确要求过) -->
|
||||
<template v-if="lastAttribution">
|
||||
<div class="menu-sep"></div>
|
||||
<div class="menu-block attrib">
|
||||
<div class="attrib-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>
|
||||
</template>
|
||||
|
||||
<p v-if="busy" class="busy">{{ busyLabel }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-menu {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 9px 6px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg3);
|
||||
border-left: 2px solid var(--line);
|
||||
padding: 4px 8px;
|
||||
line-height: 1.6;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
/* ---- 纯色:右侧一个方形色块 ---- */
|
||||
.swatch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.swatch::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.swatch::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ---- 预设:3 列卡片网格 ---- */
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.preset {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 10;
|
||||
line-height: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.preset:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.preset img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.preset-label {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ---- 上传 ---- */
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 图库 ---- */
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.tab.on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.row input {
|
||||
flex: 1;
|
||||
}
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger); /* 错误色用 danger,不是 accent */
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.key-link {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- 署名 ---- */
|
||||
.attrib {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.attrib-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.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 {
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.mini {
|
||||
padding: 3px 8px;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.busy {
|
||||
margin: 6px 9px 0;
|
||||
color: var(--fg-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 展开图库时角标转一下 */
|
||||
.menu-aside.rot {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user