diff --git a/src/components/panels/SourcePanel.vue b/src/components/panels/SourcePanel.vue deleted file mode 100644 index 3d525cd..0000000 --- a/src/components/panels/SourcePanel.vue +++ /dev/null @@ -1,597 +0,0 @@ - - - - - diff --git a/src/components/topbar/BackgroundMenu.vue b/src/components/topbar/BackgroundMenu.vue index 4793151..ee59820 100644 --- a/src/components/topbar/BackgroundMenu.vue +++ b/src/components/topbar/BackgroundMenu.vue @@ -14,10 +14,10 @@ import { useEditor } from "@/editor/useEditor"; import { useBackground } from "@/editor/useBackground"; import { fromUpload } from "@/sources/upload"; import { - createGeometryAsset, - deriveGeometryParams, + GEOMETRY_PRESETS, + createPresetAsset, + derivePresetParams, geometryToDataUrl, - type GeometryKind, } from "@/sources/geometry"; import { PHOTO_PROVIDERS, PhotoProviderError } from "@/sources/providers"; import type { Attribution, BackgroundAsset, PhotoProvider, PhotoProviderId, StockPhoto } from "@/types"; @@ -70,52 +70,29 @@ function onSolidInput(e: Event) { /* ---------------------------------------------------------------- 预设 */ -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 就定下的约束,别改回去。 + * 预设底纹的**唯一真源是 `@/sources/geometry` 的 `GEOMETRY_PRESETS`**(16 条, + * 8 种图案 × 2 档色相),不在这个组件里另立一份。 + * + * 配色由 `derivePresetParams` 压暗降饱和,保证封面上压白字可读——这是 t4-sources + * 就定下的约束,`sources/selfcheck.ts` 里有逐像素对比度断言看着,别改回去。 */ -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 缓存不重复渲)。 */ +/** 预设 + 它的缩略图(就是那张底纹本身,确定性生成,computed 缓存不重复渲)。 */ const presetThumbs = computed(() => - PRESETS.map((p) => ({ - ...p, - thumb: geometryToDataUrl(deriveGeometryParams(p.seed, p.kind, THUMB_W, THUMB_H)), + GEOMETRY_PRESETS.map((p) => ({ + id: p.id, + label: p.label, + thumb: geometryToDataUrl(derivePresetParams(p.id, THUMB_W, THUMB_H)), })), ); -function applyPreset(p: GeometryPreset) { +function applyPreset(presetId: string) { if (busy.value) return; try { - const asset = createGeometryAsset(p.seed, p.kind, size.value.width, size.value.height); + const asset = createPresetAsset(presetId, size.value.width, size.value.height); void applyAsset(asset, "生成预设底纹…"); } catch (err) { notice.value = errorText(err, "预设底纹生成失败"); @@ -264,7 +241,7 @@ async function copyAttribution(a: Attribution) { class="preset" :disabled="busy" :title="p.label" - @click="applyPreset(p)" + @click="applyPreset(p.id)" > {{ p.label }} diff --git a/src/editor/useBackground.ts b/src/editor/useBackground.ts index 824f068..58f5004 100644 --- a/src/editor/useBackground.ts +++ b/src/editor/useBackground.ts @@ -15,10 +15,11 @@ import { capImageSize } from "./imageLimits"; * 关键实现约束(都是踩出来的,别再踩): * - `current()` 每次现找,不要缓存引用——`loadFromJSON` 之后画布上是新对象, * 缓存的引用会指向一个已经不在画布上的旧对象。 - * - `selectable: false` 要放在**最后**设:`canvas.add()` 和 `sendObjectToBack()` - * 都会触发 `object:added`,而 useEditor 那个监听器会再跑一次 `registerLayer()` - * (`applyLocked(obj,false)` 会把 selectable 翻回 true),所以必须先 add、先 - * 置底,最后再关 selectable。 + * - **背景和普通图层一样可以点选、拖动、缩放。** 一开始做成了不可点选(防止点空白 + * 处误选),但用户对背景的核心需求就是「调整 / 缩放 / 裁剪一下」,不可点选就没法 + * 在画布上取景。要取消选中点画板外面的灰底就行——画布是无限工作区,画板外一大片 + * 都是空的。(顺带省掉一个坑:`selectable:false` 会被图层面板的解锁按钮 + * `applyLocked(obj,false)` 翻回 true,得靠「最后再设」这种顺序技巧才压得住。) * - 铺满用 `width * scaleX` 而不是 `getScaledWidth()`:后者会把默认 strokeWidth * 也算进去(见 common 的 Fabric 陷阱 3)。 */ @@ -110,9 +111,6 @@ export function useBackground() { registerLayer(img); c.add(img); c.sendObjectToBack(img); - // 最后再关 selectable:add/sendObjectToBack 的 object:added 会把 selectable - // 翻回 true(见文件头注释)。背景在画布上不可点选,但图层面板里仍能选中。 - img.selectable = false; c.requestRenderAll(); bumpRevision(); commit(); @@ -137,7 +135,6 @@ export function useBackground() { registerLayer(rect); c.add(rect); c.sendObjectToBack(rect); - rect.selectable = false; c.requestRenderAll(); bumpRevision(); commit(); diff --git a/src/editor/useLayers.ts b/src/editor/useLayers.ts index b10b10a..11cdaf0 100644 --- a/src/editor/useLayers.ts +++ b/src/editor/useLayers.ts @@ -99,14 +99,20 @@ export function deleteLayer(id: string) { /** * 把 `id` 挪到图层面板的第 `toRow` 行(0 = 最上层)。拖拽排序用。 * 内部换算成 fabric 的绘制序号:`绘制序号 = 对象总数 - 1 - 行号`。 + * + * 背景图层**钉在最底**:它自己拖不上去,别的图层也压不到它下面。 + * 「背景始终置于底部」是这个图层存在的意义,靠约束保证比靠用户自觉可靠。 */ export function moveLayerToRow(id: string, toRow: number) { const c = getCanvas(); const objs = c.getObjects(); const obj = find(id); if (!obj) return; - const target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow)); - c.moveObjectTo(obj, target); + if (obj.isBackground) return; + let target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow)); + // 画布上有背景时,绘制序号 0 是它的位置,别的对象最低只能到 1。 + if (target === 0 && objs.some((o) => o.isBackground)) target = 1; + c.moveObjectTo(obj, Math.min(target, objs.length - 1)); c.requestRenderAll(); bumpRevision(); commit(); diff --git a/src/sources/errors.ts b/src/sources/errors.ts new file mode 100644 index 0000000..c2b86aa --- /dev/null +++ b/src/sources/errors.ts @@ -0,0 +1,14 @@ +/** + * 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话,不让路径崩掉。 + * + * 单独一个文件而不是塞进 `providers.ts`:两家 provider 都要抛它,而 + * `providers.ts` 又要 import 两家来组注册表,定义在那里就成了循环依赖—— + * ESM 的环里谁先被加载谁踩 TDZ,症状是整页白屏。放在这个不依赖任何兄弟模块的 + * 叶子里,环从根上不成立。 + */ +export class PhotoProviderError extends Error { + constructor(message: string) { + super(message); + this.name = "PhotoProviderError"; + } +} diff --git a/src/sources/index.ts b/src/sources/index.ts index e67d3ab..f04a184 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -9,8 +9,7 @@ * - pexels.ts / unsplash.ts 图库搜索,都实现 `PhotoProvider` 接口, * Pexels 在前(`PHOTO_PROVIDERS` 见 providers.ts) * - * T4 时代的 legacy 函数面(`searchUnsplash` / `toAsset` 等)保留到旧 - * SourcePanel 被 t13 清掉为止;新界面一律走 `PHOTO_PROVIDERS`。 + * 图库一律走 `PHOTO_PROVIDERS`,不要直接 import 某一家。 */ export { fromUpload, releaseObjectUrl, pickImageFiles } from "./upload"; @@ -36,14 +35,9 @@ export type { } from "./geometry"; export { - unsplashStatus, - searchUnsplash, - trackDownload, - toAsset, parseUnsplashResponse, unsplashProvider, } from "./unsplash"; -export type { UnsplashStatus, UnsplashPhoto } from "./unsplash"; export { parsePexelsResponse, pexelsProvider } from "./pexels"; diff --git a/src/sources/pexels.ts b/src/sources/pexels.ts index b81de7d..7a25715 100644 --- a/src/sources/pexels.ts +++ b/src/sources/pexels.ts @@ -5,14 +5,14 @@ import type { PhotoProviderStatus, StockPhoto, } from "../types"; -import { PhotoProviderError } from "./unsplash"; +import { PhotoProviderError } from "./errors"; /** * Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。 * * - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: `, * 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。 - * - 共享错误类 `PhotoProviderError` 定义在 unsplash.ts 那个不依赖兄弟模块的叶子里, + * - 共享错误类 `PhotoProviderError` 在 `errors.ts`(不依赖任何兄弟模块的叶子), * 从那里 import,避免和 providers.ts 的注册表互相引成环。 * - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。 * - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家, diff --git a/src/sources/providers.ts b/src/sources/providers.ts index 744647b..be115a5 100644 --- a/src/sources/providers.ts +++ b/src/sources/providers.ts @@ -4,7 +4,7 @@ import { unsplashProvider } from "./unsplash"; // 共享错误类定义在 unsplash.ts(依赖最少的叶子模块),这里 re-export 保持桩文件 // 原本的出口路径;pexels.ts 也直接 import 它,避免 providers ↔ 各家模块引成环。 -export { PhotoProviderError } from "./unsplash"; +export { PhotoProviderError } from "./errors"; /** * 图库注册表。 diff --git a/src/sources/selfcheck.ts b/src/sources/selfcheck.ts index 7e0cb03..cb347ac 100644 --- a/src/sources/selfcheck.ts +++ b/src/sources/selfcheck.ts @@ -21,9 +21,7 @@ import { pickImageFiles } from "./upload"; import { parsePexelsResponse, pexelsProvider } from "./pexels"; import { parseUnsplashResponse, - searchUnsplash, unsplashProvider, - unsplashStatus, } from "./unsplash"; import { PHOTO_PROVIDERS, PhotoProviderError, providerById } from "./providers"; import { @@ -264,13 +262,6 @@ for (const p of PHOTO_PROVIDERS) { `${p.id}.search() 没配 key 时抛 PhotoProviderError,而不是别的`, ); } -assert.equal(unsplashStatus(), "missing", "legacy unsplashStatus 同步读同一个 key"); -await assert.rejects( - searchUnsplash("forest"), - (err) => err instanceof PhotoProviderError, - "legacy searchUnsplash 也走同一错误类(老面板显示 message 用)", -); - console.log("✓ 注册表与缺失态:Pexels 在前,没 key 时两家 status=missing、search 抛 PhotoProviderError"); /* ---------------- 4. T14:几何预设的数量与白字对比度 ---------------- */ diff --git a/src/sources/unsplash.ts b/src/sources/unsplash.ts index 360c9e2..0dc05b2 100644 --- a/src/sources/unsplash.ts +++ b/src/sources/unsplash.ts @@ -2,16 +2,13 @@ import type { Attribution, BackgroundAsset, PhotoProvider, - PhotoProviderStatus, StockPhoto, } from "../types"; +import { PhotoProviderError } from "./errors"; /** - * Unsplash 搜索。T14 起归一到 `PhotoProvider` 接口(界面只认接口,不写 - * `if (provider === "unsplash")`),同时**保留 T4 时代的 legacy 函数面** - * (`searchUnsplash` / `unsplashStatus` / `toAsset` / `trackDownload` / - * `UnsplashPhoto`):基座里的旧 SourcePanel 还直接 import 它们,删了会砸掉 - * 这一轮的 typecheck;等 t13 把旧面板清掉后这些只是没人用的历史出口。 + * Unsplash 搜索,归一到 `PhotoProvider` 接口——界面只认接口,不写 + * `if (provider === "unsplash")`。 * * - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`。 * - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示, @@ -21,84 +18,6 @@ import type { * - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。 */ -/* ------------------------------------------------------------------ 共享错误 */ - -/** - * 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话。 - * - * 故意定义在这个**不依赖任何兄弟模块**的叶子里:`pexels.ts` 从这 import、 - * `providers.ts` 从这里 re-export。若定义在 providers.ts 而让两家 provider - * 反过去 import 它,就会和 providers 的注册表互相 import 成环——ESM 环里 - * 谁先被加载谁就踩 TDZ(旧 SourcePanel 只 import unsplash 时会先踩到)。 - */ -export class PhotoProviderError extends Error { - constructor(message: string) { - super(message); - this.name = "PhotoProviderError"; - } -} - -/* ------------------------------------------------------------------ legacy 类型 */ - -export type UnsplashStatus = PhotoProviderStatus; - -/** 一张搜索结果,带网格展示与装进画布所需的全部信息(T4 时代的形状)。 */ -export interface UnsplashPhoto { - id: string; - width: number; - height: number; - description: string | null; - altDescription: string | null; - authorName: string; - thumbUrl: string; - /** 装进画布用的尺寸(regular,约 1080px)。 */ - regularUrl: string; - /** 原图页(带 utm_source,署名链接用)。 */ - pageUrl: string; - /** API 要求的 download_location,选定该图时触发。 */ - downloadLocation: string; -} - -/* ------------------------------------------------------------------ 状态 */ - -/** 当前是否配了 key。env 在 Vite 里必然有;Node/Bun/构建产物里可能没有,一律当缺失。 */ -export function unsplashStatus(): UnsplashStatus { - return readKey() ? "ready" : "missing"; -} - -/* ------------------------------------------------------------------ legacy 导出(T4 SourcePanel 还在用) */ - -/** legacy:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */ -export function toAsset(photo: UnsplashPhoto): BackgroundAsset { - const attribution: Attribution = { - text: `Photo by ${photo.authorName} on Unsplash`, - url: photo.pageUrl, - }; - return { - kind: "stock", - src: photo.regularUrl, - width: photo.width, - height: photo.height, - attribution, - }; -} - -/** legacy:单页搜索(不带分页参数),结果仍是 UnsplashPhoto。新界面走 provider。 */ -export async function searchUnsplash(query: string): Promise { - const raws = await fetchRawUnsplash(query, 1, 12); - return raws.map(toLegacyPhoto); -} - -/** legacy:用户选定一张图时触发 download 统计,条款义务。失败静默。 */ -export function trackDownload(photo: UnsplashPhoto): void { - const key = readKey(); - if (!key || !photo.downloadLocation) return; - const sep = photo.downloadLocation.includes("?") ? "&" : "?"; - fetch(`${photo.downloadLocation}${sep}client_id=${encodeURIComponent(key)}`).catch(() => { - /* download 统计失败不影响用户操作 */ - }); -} - /* ------------------------------------------------------------------ PhotoProvider */ /** 纯函数解析:官方响应样本 → 归一化 StockPhoto[]。selfcheck 在 Node 里喂样本断言。 */ @@ -114,7 +33,7 @@ export const unsplashProvider: PhotoProvider = { label: "Unsplash", // 申请 key 的页面,status 为 missing 时界面把它显示出来。 keyUrl: "https://unsplash.com/oauth/applications", - status: unsplashStatus, + status: () => (readKey() ? "ready" : "missing"), search: async (query, page) => stocksFromRaws(await fetchRawUnsplash(query, page, 15)), toAsset: toStockAsset, /** 条款义务:用户选定一张图时打一次 download 统计。只有 Unsplash 有。 */ @@ -180,10 +99,6 @@ function authorNameOf(raw: UnsplashRawPhoto): string { return raw.user?.name?.trim() || "Unsplash 用户"; } -function pageUrlOf(raw: UnsplashRawPhoto): string { - return raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`; -} - /** * raw → StockPhoto(provider / parseUnsplashResponse 共用)。 * 空字段太多、连缩略图和原图页都没有的结果没法用,返回 null 让上层跳过。 @@ -212,24 +127,6 @@ function toStockPhoto(raw: UnsplashRawPhoto): StockPhoto | null { }; } -/** raw → legacy UnsplashPhoto(保持 T4 语义原样,别和 StockPhoto 解析互相牵连)。 */ -function toLegacyPhoto(raw: UnsplashRawPhoto): UnsplashPhoto { - const authorName = authorNameOf(raw); - const pageUrl = pageUrlOf(raw); - return { - id: raw.id ?? "", - width: raw.width ?? 0, - height: raw.height ?? 0, - description: raw.description ?? null, - altDescription: raw.alt_description ?? null, - authorName, - thumbUrl: raw.urls?.small || raw.urls?.thumb || raw.urls?.regular || "", - regularUrl: raw.urls?.regular || raw.urls?.raw || "", - pageUrl, - downloadLocation: raw.links?.download_location ?? "", - }; -} - /** raw 数组 → StockPhoto[](provider.search 用;parse 系列只认整棵官方响应)。 */ function stocksFromRaws(raws: readonly UnsplashRawPhoto[]): StockPhoto[] { const out: StockPhoto[] = [];