diff --git a/src/components/ToolPanels.vue b/src/components/ToolPanels.vue index dddf4b8..a70f099 100644 --- a/src/components/ToolPanels.vue +++ b/src/components/ToolPanels.vue @@ -1,30 +1,213 @@ diff --git a/src/components/panels/FilterPanel.vue b/src/components/panels/FilterPanel.vue index f361858..043eb4f 100644 --- a/src/components/panels/FilterPanel.vue +++ b/src/components/panels/FilterPanel.vue @@ -6,7 +6,7 @@ import { FILTERS, FILTER_TYPES } from "@/editor/filters"; import { useEditor } from "@/editor/useEditor"; /** - * 滤镜面板。选中图片时显示(Sidebar 已按 selectionKind === "image" 挂载)。 + * 滤镜面板。ToolPanels 的「滤镜」分组在**选中图片时**展开并显示内容。 * * 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。 * 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算, diff --git a/src/components/panels/SourcePanel.vue b/src/components/panels/SourcePanel.vue index c8f4ddb..3d525cd 100644 --- a/src/components/panels/SourcePanel.vue +++ b/src/components/panels/SourcePanel.vue @@ -1,21 +1,21 @@ @@ -277,15 +308,23 @@ onBeforeUnmount(() => {

本地上传

+ -

或直接把图片拖进窗口任意位置,自动铺满画布。换图时旧地址自动释放。

+

+ 也可以直接把图片拖进窗口任意位置。默认作为新图层加在最上层并选中, + 多张可一次全加;勾上「作为背景铺满」则铺满画布当背景。 +

@@ -375,7 +414,9 @@ onBeforeUnmount(() => { @@ -402,6 +443,19 @@ onBeforeUnmount(() => { 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; diff --git a/src/sources/selfcheck.ts b/src/sources/selfcheck.ts new file mode 100644 index 0000000..3920588 --- /dev/null +++ b/src/sources/selfcheck.ts @@ -0,0 +1,68 @@ +/** + * 【T10】Node 侧自验:多文件拖放的取值逻辑。运行:`bun src/sources/selfcheck.ts` + * + * 拖放一次可能给一批混合文件(图片、文档、压缩包…),这条线把「从里面 + * 挑出图片、保持相对顺序」抽成纯函数 `pickImageFiles`(src/sources/upload.ts), + * 这里断言它:只挑图片、顺序不变、空 type / 非图片全部排除。取值逻辑不 + * 依赖浏览器,Node 里就能验;真正把图拖进窗口的行为由主控用 Chrome 验。 + */ +import assert from "node:assert/strict"; +import { pickImageFiles } from "./upload"; + +/** 模拟 File 的最小形状:拖放取值只用到 mime type。 */ +interface FakeFile { + name: string; + type: string; +} + +/* ---------------- 1. 混合文件:只挑图片,顺序不变 ---------------- */ + +const mixed: FakeFile[] = [ + { name: "封面主图.png", type: "image/png" }, + { name: "说明.txt", type: "text/plain" }, + { name: "背景.jpg", type: "image/jpeg" }, + { name: "archive.zip", type: "application/zip" }, + { name: "动画.gif", type: "image/gif" }, + { name: "数据.json", type: "application/json" }, + { name: "矢量.svg", type: "image/svg+xml" }, +]; + +const picked = pickImageFiles(mixed); +assert.deepEqual( + picked.map((f) => f.name), + ["封面主图.png", "背景.jpg", "动画.gif", "矢量.svg"], + "只挑出图片,且相对顺序与拖入时一致", +); + +console.log("✓ 混合文件:4 张图片被挑出,txt/zip/json 被剔除,顺序不变"); + +/* ---------------- 2. 边界:没有图片 / 空 type / 空数组 ---------------- */ + +assert.equal(pickImageFiles([]).length, 0, "空拖放 → 空结果"); + +const noImage: FakeFile[] = [ + { name: "笔记.txt", type: "text/plain" }, + { name: "无扩展名", type: "" }, // 系统认不出 mime 的也当非图片 + { name: "包.zip", type: "application/zip" }, +]; +assert.equal(pickImageFiles(noImage).length, 0, "不含图片时返回空数组,不报错"); + +const typeLess: FakeFile[] = [{ name: "a.webp", type: "image/webp" }, { name: "b", type: "" }]; +assert.deepEqual( + pickImageFiles(typeLess).map((f) => f.name), + ["a.webp"], + "type 为空的文件不进(不猜后缀)", +); + +/* ---------------- 3. 全是图片:原样全收,顺序不变 ---------------- */ + +const allImage: FakeFile[] = [ + { name: "1.webp", type: "image/webp" }, + { name: "2.png", type: "image/png" }, + { name: "3.bmp", type: "image/bmp" }, +]; +assert.deepEqual(pickImageFiles(allImage), allImage, "全是图片时顺序原样返回"); + +console.log("✓ 边界:空 / 全非图片 / 空 type / 全图片,四种情况取值都正确"); + +console.log("\n全部 Node 侧自验通过 ✅"); diff --git a/src/sources/upload.ts b/src/sources/upload.ts index cbe827d..ce6b5a4 100644 --- a/src/sources/upload.ts +++ b/src/sources/upload.ts @@ -3,10 +3,24 @@ import type { BackgroundAsset } from "../types"; /** * 本地上传:本地文件 → object URL。 * - * object URL 的生命周期由调用方(SourcePanel)管理:换新背景时对旧的 - * `releaseObjectUrl`,组件卸载时兜底释放一次,避免每次换图都漏一个 blob。 + * object URL 的生命周期由调用方(SourcePanel)管理。自 T10 起拖入的图片 + * 是**加图层**而不是替换背景:一个会话里可能有多张存活的 blob URL + * (撤销 / 模板重载时要重新取图,不能提前 revoke),调用方负责在组件 + * 卸载时统一释放,避免每拖一张都漏一个 blob。 */ +/** + * 拖放/多选的取值逻辑:**只挑图片,保持原顺序**。 + * + * 参数用最小结构 `{ type }` 而不是 DOM 的 `File`,是为了能在 Node 侧直接 + * 断言(见 `src/sources/selfcheck.ts`)——取值只依赖 mime,不碰文件内容。 + * 拖放进来的一批文件可能是图片、文档、压缩包的混合,非图片直接忽略, + * 别让一张 txt 把整批拖放打断。 + */ +export function pickImageFiles(files: readonly T[]): T[] { + return files.filter((f) => f.type.startsWith("image/")); +} + /** 把本地图片文件变成 BackgroundAsset。非图片/解不出尺寸会抛错并释放 URL。 */ export async function fromUpload(file: File): Promise { const src = URL.createObjectURL(file);