feat: 专用背景图层与顶栏背景下拉(T13)
- useBackground:全画布最多一个背景图层,换背景/纯色/预设/上传/图库都替换它而非堆叠; 背景 isBackground 标记、置底、默认不可点选但图层面板可选中,画板尺寸变了重新铺满。 - BackgroundMenu:纯色 / 预设(12 个压暗降饱和底纹) / 上传 / 图库四段下拉面板。 - setBackground 走 capImageSize,超过纹理上限的图先缩再进画布,避免加滤镜后缺边。
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
+141
-20
@@ -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 {},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user