Merge branch 't13-background'

This commit is contained in:
2026-09-05 18:25:02 +08:00
2 changed files with 693 additions and 22 deletions
+552 -2
View File
@@ -1,7 +1,557 @@
<script setup lang="ts"> <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> </script>
<template> <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> </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>
+141 -20
View File
@@ -1,36 +1,157 @@
import type { FabricObject } from "fabric"; import { FabricImage, Rect, type FabricObject } from "fabric";
import { watch } from "vue";
import type { BackgroundAsset } from "@/types"; import type { BackgroundAsset } from "@/types";
import { useEditor } from "./useEditor";
import { commit } from "./useHistory";
import { capImageSize } from "./imageLimits";
/** /**
* 【T13 待实现】专用背景图层。 * 【T13】专用背景图层。
* *
* **背景不是「再加一张图片图层」**,这是这条线的全部要点:全画布最多一个背景图层, * **背景不是「再加一张图片图层」**,这是这条线的全部要点:全画布最多一个背景图层,
* 它永远在最底(`sendObjectToBack`),换背景是**替换**它而不是往上堆。 * 它永远在最底(`sendObjectToBack`),换背景是**替换**它而不是往上堆。
* 对象上用 `obj.isBackground = true` 标记(已登记进 customProperties,能存进模板)。 * 对象上用 `obj.isBackground = true` 标记(已登记进 customProperties,能存进模板)。
* *
* 契约: * 关键实现约束(都是踩出来的,别再踩):
* - `current()` 返回当前背景对象,没有返回 null。找法是遍历 `canvas.getObjects()` * - `current()` 每次现找,不要缓存引用——`loadFromJSON` 之后画布上是新对象,
* 找 `isBackground` 为真的那个,**不要另存一份引用**——`loadFromJSON` 之后 * 缓存的引用会指向一个已经不在画布上的旧对象。
* 画布上的是新对象,缓存的引用会指向一个已经不在画布上的旧对象。 * - `selectable: false` 要放在**最后**设:`canvas.add()` 和 `sendObjectToBack()`
* - `setBackground(asset)` 装一份素材当背景:有旧的就换掉,按 `fit` 铺,置底, * 都会触发 `object:added`,而 useEditor 那个监听器会再跑一次 `registerLayer()`
* `registerLayer()`,`bumpRevision()`,然后 `commit()` 记一步历史。 * (`applyLocked(obj,false)` 会把 selectable 翻回 true),所以必须先 add、先
* - `setSolid(color)` 纯色背景,用一个盖满画板的 Rect。 * 置底,最后再关 selectable。
* - `clear()` 删掉背景图层。 * - 铺满用 `width * scaleX` 而不是 `getScaledWidth()`:后者会把默认 strokeWidth
* - 背景默认**不可选中**(`selectable: false`),否则点画布空白处老是选中它; * 也算进去(见 common 的 Fabric 陷阱 3)。
* 但它要出现在图层面板里,可以在那里选中、锁定、隐藏、删除。
* - 画板尺寸变了要重新铺(listen `useEditor().size`)。
*/ */
const { canvas, getCanvas, registerLayer, bumpRevision, size } = useEditor();
/** 当前背景对象,没有返回 null。**每次现找**,别缓存。 */
function current(): FabricObject | null {
const c = canvas.value;
if (!c) return null;
return c.getObjects().find((o) => o.isBackground === true) ?? null;
}
/**
* 删掉现有背景(没有就什么都不做)。
* fabric 的 `canvas.remove` 不会清 active——若被删的正是当前选中对象(用户在图层
* 面板里选中了背景再换背景),activeObject 会悬在一个已离画的旧对象上,所以要补一刀。
*/
function removeOld(): FabricObject | null {
const c = getCanvas();
const old = current();
if (!old) return null;
c.remove(old);
if (c.getActiveObject() === old) c.discardActiveObject();
return old;
}
/**
* 把背景对象按当前画板尺寸铺满。图片走 cover(等比放大到盖满,多出来裁掉),
* 纯色 Rect 直接撑满。画板尺寸变了也走这里重新铺。
*/
function fitToCanvas(obj: FabricObject): void {
const w = size.value.width;
const h = size.value.height;
if (obj instanceof FabricImage) {
// cover:`width` 是原始像素,缩放另挂在 scaleX/scaleY 上,按画板等比取较大者。
const scale = Math.max(w / obj.width, h / obj.height);
obj.set({
scaleX: scale,
scaleY: scale,
left: w / 2,
top: h / 2,
originX: "center",
originY: "center",
});
} else {
// 纯色 Rect。fabric 默认原点是中心,这里必须显式写左上角。
obj.set({
left: 0,
top: 0,
originX: "left",
originY: "top",
width: w,
height: h,
});
}
}
/** 画板尺寸变了重新铺背景。尺寸变化本身由 setSize 负责,这里不 commit。 */
watch(
() => [size.value.width, size.value.height],
() => {
const bg = current();
if (!bg) return;
fitToCanvas(bg);
canvas.value?.requestRenderAll();
bumpRevision();
},
);
export function useBackground() { export function useBackground() {
return { return {
current(): FabricObject | null { current,
return null;
/** 装一份素材当背景:有旧的先换掉,按 cover 铺满,置底,记一步历史。 */
async setBackground(asset: BackgroundAsset): Promise<FabricObject | null> {
const c = getCanvas();
// 背景是替换不是叠加:先删旧的,全画布最多一个背景图层。
removeOld();
const img = await FabricImage.fromURL(asset.src, { crossOrigin: "anonymous" });
// 图库/上传的原图动辄 5000-6000px,超过 fabric 纹理上限后加滤镜会缺右边和下边,
// 进画布前先缩一道(需要缩才缩,见 imageLimits.ts)。setElement 之后 img.width
// 就是缩后的尺寸,fitToCanvas 按它铺满。
const capped = capImageSize(img.getElement() as HTMLImageElement);
if (capped !== img.getElement()) img.setElement(capped);
img.isBackground = true;
fitToCanvas(img);
registerLayer(img);
c.add(img);
c.sendObjectToBack(img);
// 最后再关 selectable:add/sendObjectToBack 的 object:added 会把 selectable
// 翻回 true(见文件头注释)。背景在画布上不可点选,但图层面板里仍能选中。
img.selectable = false;
c.requestRenderAll();
bumpRevision();
commit();
return img;
}, },
async setBackground(_asset: BackgroundAsset): Promise<FabricObject | null> {
return null; /** 纯色背景:一个盖满画板的 Rect。 */
setSolid(color: string): FabricObject | null {
const c = getCanvas();
removeOld();
const rect = new Rect({
left: 0,
top: 0,
originX: "left",
originY: "top",
width: size.value.width,
height: size.value.height,
fill: color,
});
rect.isBackground = true;
registerLayer(rect);
c.add(rect);
c.sendObjectToBack(rect);
rect.selectable = false;
c.requestRenderAll();
bumpRevision();
commit();
return rect;
}, },
setSolid(_color: string): FabricObject | null {
return null; /** 删掉背景图层(没有就什么都不做)。 */
clear(): void {
if (!canvas.value) return;
// 没有背景时什么都不做,避免无谓的 bumpRevision / 历史快照。
if (!removeOld()) return;
getCanvas().requestRenderAll();
bumpRevision();
commit();
}, },
clear(): void {},
}; };
} }