Merge branch 't10-panels' into integration2
This commit is contained in:
+194
-11
@@ -1,30 +1,213 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
import SourcePanel from "./panels/SourcePanel.vue";
|
||||||
|
import FilterPanel from "./panels/FilterPanel.vue";
|
||||||
|
import TextPanel from "./panels/TextPanel.vue";
|
||||||
|
import ExportPanel from "./panels/ExportPanel.vue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T10 待实现 — 工具面板的重新安置】
|
* 【T10 — 工具面板的重新安置】
|
||||||
*
|
*
|
||||||
* 旧版把所有东西塞在一个右侧栏里。改成 Figma 式布局之后,那四个面板
|
* 旧版把所有东西塞在一个右侧栏里。改成 Figma 式布局之后,那四个面板
|
||||||
* (背景 / 滤镜 / 文字 / 导出与模板)需要重新安置,它们的功能都还在,
|
* (背景 / 滤镜 / 文字 / 导出与模板)没有任何挂载点——功能不可达。这里
|
||||||
* 只是现在没有任何地方挂载它们——**改造之后这些功能在界面上不可达**,
|
* 把它们收成**可折叠的分组**,占右栏下半层(App.vue 已挂好,不动布局):
|
||||||
* 这条线就是来补上的。
|
|
||||||
*
|
*
|
||||||
* 现有实现在 `components/panels/`:SourcePanel / FilterPanel / TextPanel /
|
* | 组 | 来源 | 内容何时可用 |
|
||||||
* ExportPanel / CanvasPanel。逻辑是好的,验收过,**不要重写**,只搬和收。
|
* |----------|---------------|---------------------------|
|
||||||
|
* | 背景 | SourcePanel | 常驻 |
|
||||||
|
* | 滤镜 | FilterPanel | 选中图片时 |
|
||||||
|
* | 文字 | TextPanel | 选中文字时 |
|
||||||
|
* | 导出与模板| ExportPanel | 常驻 |
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 分组标题可手动折叠(展开与否只是 UI 状态,不销毁面板)。
|
||||||
|
* - **选中对象自动展开相关的那组**:选中图片自动展开「滤镜」,
|
||||||
|
* 选中文字自动展开「文字」。
|
||||||
|
* - 内容是否可用看 `selectionKind`(filter 要图片、text 要文字)。
|
||||||
|
* 面板组件**保持挂载**(用 v-show 而不是 v-if 隐藏),这样它们内部
|
||||||
|
* watch activeObject 的状态能一直跟随画布;条件不满足时组内显示一行提示。
|
||||||
*/
|
*/
|
||||||
|
const { selectionKind } = useEditor();
|
||||||
|
|
||||||
|
type GroupId = "bg" | "filter" | "text" | "export";
|
||||||
|
|
||||||
|
/** 每组是否展开。展开与否是纯 UI 状态;组的内容是否可用看 selectionKind。 */
|
||||||
|
const open = reactive<Record<GroupId, boolean>>({
|
||||||
|
bg: true,
|
||||||
|
filter: false,
|
||||||
|
text: false,
|
||||||
|
export: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isImage = computed(() => selectionKind.value === "image");
|
||||||
|
const isText = computed(() => selectionKind.value === "text");
|
||||||
|
|
||||||
|
/** 选中对象类型变化 → 自动展开相关的那组。只展开不收起,收起是用户自己的事。 */
|
||||||
|
watch(selectionKind, (kind) => {
|
||||||
|
if (kind === "image") open.filter = true;
|
||||||
|
else if (kind === "text") open.text = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(id: GroupId) {
|
||||||
|
open[id] = !open[id];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tool-panels">
|
<div class="tool-panels">
|
||||||
<p class="todo">T10 待实现:背景 / 滤镜 / 文字 / 导出与模板</p>
|
<!-- ================= 背景 ================= -->
|
||||||
|
<section class="tp-sec" :class="{ open: open.bg }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tp-head"
|
||||||
|
:aria-expanded="open.bg"
|
||||||
|
@click="toggle('bg')"
|
||||||
|
>
|
||||||
|
<span class="chev" aria-hidden="true"></span>
|
||||||
|
<span class="tp-title">背景</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="open.bg" class="tp-body">
|
||||||
|
<SourcePanel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 滤镜(选中图片时可用) ================= -->
|
||||||
|
<section class="tp-sec" :class="{ open: open.filter }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tp-head"
|
||||||
|
:aria-expanded="open.filter"
|
||||||
|
@click="toggle('filter')"
|
||||||
|
>
|
||||||
|
<span class="chev" aria-hidden="true"></span>
|
||||||
|
<span class="tp-title">滤镜</span>
|
||||||
|
<span class="tp-scope">作用于图片</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="open.filter" class="tp-body">
|
||||||
|
<FilterPanel v-show="isImage" />
|
||||||
|
<p v-if="!isImage" class="tp-hint">
|
||||||
|
在画布上选中一张图片后,这一组会自动展开,列出可叠加的滤镜。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 文字(选中文字时可用) ================= -->
|
||||||
|
<section class="tp-sec" :class="{ open: open.text }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tp-head"
|
||||||
|
:aria-expanded="open.text"
|
||||||
|
@click="toggle('text')"
|
||||||
|
>
|
||||||
|
<span class="chev" aria-hidden="true"></span>
|
||||||
|
<span class="tp-title">文字</span>
|
||||||
|
<span class="tp-scope">作用于文字</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="open.text" class="tp-body">
|
||||||
|
<TextPanel v-show="isText" />
|
||||||
|
<p v-if="!isText" class="tp-hint">
|
||||||
|
用顶栏文字工具(T)添加文字,或选中画布上已有的文字,再到这里调字体样式。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 导出与模板 ================= -->
|
||||||
|
<section class="tp-sec" :class="{ open: open.export }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tp-head"
|
||||||
|
:aria-expanded="open.export"
|
||||||
|
@click="toggle('export')"
|
||||||
|
>
|
||||||
|
<span class="chev" aria-hidden="true"></span>
|
||||||
|
<span class="tp-title">导出与模板</span>
|
||||||
|
</button>
|
||||||
|
<div v-show="open.export" class="tp-body">
|
||||||
|
<ExportPanel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tool-panels {
|
.tool-panels {
|
||||||
padding: 10px 0;
|
padding: 2px 0 18px;
|
||||||
}
|
}
|
||||||
.todo {
|
|
||||||
|
/* 分组之间一条细分隔线,整体顶到右栏上边缘 */
|
||||||
|
.tp-sec {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.tp-sec:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组标题:整行可点,覆盖全局 button 外观 */
|
||||||
|
.tp-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.tp-head:hover {
|
||||||
|
background: var(--bg3);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.tp-head:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开/收起的三角,收拢朝右、展开朝下 */
|
||||||
|
.chev {
|
||||||
|
flex: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid var(--fg-dim);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.tp-sec.open .chev {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
margin: 0 12px;
|
}
|
||||||
line-height: 1.6;
|
.tp-sec.open .tp-title {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组的适用范围提示,靠右 */
|
||||||
|
.tp-scope {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
opacity: 0.85;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-body {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组展开了但条件不满足(没选图片/文字)时给一行提示,不渲染控制项 */
|
||||||
|
.tp-hint {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 2px 0 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { FILTERS, FILTER_TYPES } from "@/editor/filters";
|
|||||||
import { useEditor } from "@/editor/useEditor";
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滤镜面板。选中图片时显示(Sidebar 已按 selectionKind === "image" 挂载)。
|
* 滤镜面板。ToolPanels 的「滤镜」分组在**选中图片时**展开并显示内容。
|
||||||
*
|
*
|
||||||
* 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
|
* 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
|
||||||
* 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
|
* 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 【T4 — 背景来源面板】
|
* 【T4→T10 — 背景来源面板】
|
||||||
*
|
*
|
||||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。任一来源选中的素材都调
|
* 三个来源:本地上传、预设几何、Unsplash 搜索。
|
||||||
* `useEditor().addImage(asset.src)` 装进画布(按 cover 方式铺满并选中)。
|
|
||||||
*
|
*
|
||||||
* - 上传:文件选择 + 全窗口拖放。object URL 换图时释放旧的。
|
* - 上传:文件选择 + 全窗口拖放。T10 起拖放/多选的默认行为是**新建一个图层**
|
||||||
* - 预设几何:种子输入(建议填标题)+ 四张实时缩略图。同种子必同图。
|
* (`addImage` 的 `toBack: false`:加在最上层并选中,多张一次全加);
|
||||||
* - Unsplash:没配 key 只显示提示,另外两个来源照常可用;
|
* 面板里的「作为背景铺满」勾上才铺满画布当背景(旧行为)。
|
||||||
* 搜索结果点一张装 `regular` 尺寸;选定即触发 download 统计(条款义务);
|
* object URL 全部存活到卸载时统一释放——撤销与模板重载还要重新取图。
|
||||||
* 署名不画进图,显示在面板上给复制按钮。
|
* - 预设几何 / Unsplash:保持 T4 验收过的「铺背景」语义,不受上面那个勾选影响。
|
||||||
*
|
*
|
||||||
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
|
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
|
||||||
*/
|
*/
|
||||||
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
|
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
|
||||||
import { useEditor } from "@/editor/useEditor";
|
import { useEditor } from "@/editor/useEditor";
|
||||||
import { fromUpload, releaseObjectUrl } from "@/sources/upload";
|
import { commit } from "@/editor/useHistory";
|
||||||
|
import { fromUpload, pickImageFiles, releaseObjectUrl } from "@/sources/upload";
|
||||||
import {
|
import {
|
||||||
GEOMETRY_KINDS,
|
GEOMETRY_KINDS,
|
||||||
deriveGeometryParams,
|
deriveGeometryParams,
|
||||||
@@ -45,20 +45,21 @@ function errorText(err: unknown, fallback: string): string {
|
|||||||
return err instanceof Error && err.message ? err.message : fallback;
|
return err instanceof Error && err.message ? err.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 三个来源共用的一条路径:素材 → 替换旧上传 URL → 进画布 → 记署名。 */
|
/**
|
||||||
async function place(asset: BackgroundAsset, label: string) {
|
* 「点一下」来源(几何缩略图 / Unsplash 结果)共用的一条路径:素材 → 进画布 → 记署名。
|
||||||
|
* `toBack: true` = 作为背景铺满(沉到最底层);false = 作为图层加在最上层并选中。
|
||||||
|
* 成功记一步历史,撤销能整步撤掉。
|
||||||
|
*/
|
||||||
|
async function place(asset: BackgroundAsset, label: string, toBack: boolean) {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
|
if (asset.kind === "upload") liveUploadUrls.push(asset.src); // 撤销/模板重载要重新取图
|
||||||
busyLabel.value = label;
|
busyLabel.value = label;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
notice.value = "";
|
notice.value = "";
|
||||||
try {
|
try {
|
||||||
// 换背景 = 替换:上一张本地上传的 object URL 不再需要,释放掉。
|
await addImage(asset.src, { toBack });
|
||||||
if (lastUploadUrl.value && lastUploadUrl.value !== asset.src) {
|
|
||||||
releaseObjectUrl(lastUploadUrl.value);
|
|
||||||
}
|
|
||||||
lastUploadUrl.value = asset.kind === "upload" ? asset.src : "";
|
|
||||||
await addImage(asset.src);
|
|
||||||
lastAttribution.value = asset.attribution ?? null;
|
lastAttribution.value = asset.attribution ?? null;
|
||||||
|
commit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notice.value = errorText(err, "图片没能装进画布");
|
notice.value = errorText(err, "图片没能装进画布");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,38 +70,71 @@ async function place(asset: BackgroundAsset, label: string) {
|
|||||||
/* ---------------------------------------------------------------- 本地上传 */
|
/* ---------------------------------------------------------------- 本地上传 */
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const lastUploadUrl = ref("");
|
/**
|
||||||
|
* 勾上「作为背景铺满」时,上传/拖放的图片沉到最底层当背景(旧行为);
|
||||||
|
* 默认不勾——拖进来是**新建一个图层**,加在最上层并选中它。
|
||||||
|
*/
|
||||||
|
const asBackground = ref(false);
|
||||||
|
/**
|
||||||
|
* 本会话所有上传产生的 object URL。不提前 revoke:撤销与模板重载时
|
||||||
|
* fabric 还要按 src 重新取图,提前放掉会让历史/模板里的图片变空白。
|
||||||
|
*/
|
||||||
|
const liveUploadUrls: string[] = [];
|
||||||
|
|
||||||
function onFilePick(e: Event) {
|
function onFilePick(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const files = input.files ? Array.from(input.files) : [];
|
||||||
input.value = ""; // 允许重复选同一个文件
|
input.value = ""; // 允许重复选同一个文件
|
||||||
if (file) void applyFile(file);
|
if (files.length > 0) void addFiles(files, "选中的文件里没有图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyFile(file: File) {
|
/**
|
||||||
|
* 上传/拖放共用的入口:这一批图片**一次全加进画布**(多选或一次拖多张都支持)。
|
||||||
|
* 每张先读成本地 object URL 再进画布;全部处理完记一步历史(一次拖放 = 一步撤销)。
|
||||||
|
* 默认每张都作为新图层加在最上层并选中(最新一张在最上);
|
||||||
|
* 勾了「作为背景铺满」则每张沉到最底层当背景。
|
||||||
|
*/
|
||||||
|
async function addFiles(files: readonly File[], emptyMessage: string) {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
if (!file.type.startsWith("image/")) {
|
const images = pickImageFiles(files);
|
||||||
notice.value = "只能放图片文件";
|
if (images.length === 0) {
|
||||||
|
notice.value = emptyMessage;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busyLabel.value = "读取图片…";
|
busyLabel.value = images.length > 1 ? `载入 ${images.length} 张图片…` : "读取图片…";
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
notice.value = "";
|
notice.value = "";
|
||||||
let asset: BackgroundAsset;
|
let ok = 0;
|
||||||
|
const failed: string[] = [];
|
||||||
try {
|
try {
|
||||||
asset = await fromUpload(file);
|
for (const file of images) {
|
||||||
} catch (err) {
|
try {
|
||||||
notice.value = errorText(err, "图片读不出来");
|
const asset = await fromUpload(file);
|
||||||
|
await addImage(asset.src, { toBack: asBackground.value });
|
||||||
|
liveUploadUrls.push(asset.src);
|
||||||
|
lastAttribution.value = asset.attribution ?? null;
|
||||||
|
ok++;
|
||||||
|
} catch {
|
||||||
|
failed.push(file.name || "未命名文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
busy.value = false;
|
if (ok > 0) commit();
|
||||||
await place(asset, "载入图片…");
|
if (failed.length > 0) {
|
||||||
|
notice.value =
|
||||||
|
ok === 0
|
||||||
|
? `这 ${failed.length} 张都加不进去(图片读不出来)`
|
||||||
|
: `${ok} 张已加入,${failed.length} 张失败:${failed.slice(0, 2).join("、")}${failed.length > 2 ? "…" : ""}`;
|
||||||
|
} else if (ok > 1) {
|
||||||
|
notice.value = `已加进 ${ok} 张图片(最新一张在最上层,顺序可在左栏图层里调)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 全窗口拖放:文件拖进窗口任意处即作为背景载入。
|
/* 全窗口拖放:文件拖进窗口任意处,默认作为新图层加入(或按勾选铺底)。
|
||||||
面板常驻显示,所以在面板上挂 window 级监听即可覆盖画布区。 */
|
面板常驻挂载(ToolPanels 里 v-show 控制显隐),所以在面板上挂 window 级监听
|
||||||
|
即可覆盖画布区,折叠分组不影响拖放功能。 */
|
||||||
const dragging = ref(false);
|
const dragging = ref(false);
|
||||||
let dragDepth = 0;
|
let dragDepth = 0;
|
||||||
|
|
||||||
@@ -133,14 +167,9 @@ function onDrop(e: DragEvent) {
|
|||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
f.type.startsWith("image/"),
|
if (files.length === 0) return;
|
||||||
);
|
void addFiles(files, "拖进来的文件里没有图片");
|
||||||
if (!file) {
|
|
||||||
notice.value = "拖进来的不是图片";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void applyFile(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 预设几何 */
|
/* ---------------------------------------------------------------- 预设几何 */
|
||||||
@@ -183,7 +212,7 @@ function addGeometry(kind: GeometryKind) {
|
|||||||
size.value.width,
|
size.value.width,
|
||||||
size.value.height,
|
size.value.height,
|
||||||
);
|
);
|
||||||
void place(asset, "生成几何底纹…");
|
void place(asset, "生成几何底纹…", true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notice.value = errorText(err, "几何底纹生成失败");
|
notice.value = errorText(err, "几何底纹生成失败");
|
||||||
}
|
}
|
||||||
@@ -219,7 +248,7 @@ async function doSearch() {
|
|||||||
async function pickPhoto(photo: UnsplashPhoto) {
|
async function pickPhoto(photo: UnsplashPhoto) {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
|
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
|
||||||
await place(toAsset(photo), "载入 Unsplash 图…");
|
await place(toAsset(photo), "载入 Unsplash 图…", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 署名复制 */
|
/* ---------------------------------------------------------------- 署名复制 */
|
||||||
@@ -266,7 +295,9 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener("drop", onDrop);
|
window.removeEventListener("drop", onDrop);
|
||||||
window.clearTimeout(thumbTimer);
|
window.clearTimeout(thumbTimer);
|
||||||
stopWatch?.();
|
stopWatch?.();
|
||||||
if (lastUploadUrl.value) releaseObjectUrl(lastUploadUrl.value);
|
// 本会话所有上传的 object URL 一次性释放。
|
||||||
|
liveUploadUrls.forEach(releaseObjectUrl);
|
||||||
|
liveUploadUrls.length = 0;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -277,15 +308,23 @@ onBeforeUnmount(() => {
|
|||||||
<!-- ============ 本地上传 ============ -->
|
<!-- ============ 本地上传 ============ -->
|
||||||
<h3 class="block-title">本地上传</h3>
|
<h3 class="block-title">本地上传</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<label class="mode">
|
||||||
|
<input v-model="asBackground" type="checkbox" :disabled="busy" />
|
||||||
|
<span>作为背景铺满(沉到最底层)</span>
|
||||||
|
</label>
|
||||||
<button class="wide" :disabled="busy" @click="fileInput?.click()">选择图片文件…</button>
|
<button class="wide" :disabled="busy" @click="fileInput?.click()">选择图片文件…</button>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="onFilePick"
|
@change="onFilePick"
|
||||||
/>
|
/>
|
||||||
<p class="hint">或直接把图片拖进窗口任意位置,自动铺满画布。换图时旧地址自动释放。</p>
|
<p class="hint">
|
||||||
|
也可以直接把图片拖进窗口任意位置。默认作为<strong>新图层</strong>加在最上层并选中,
|
||||||
|
多张可一次全加;勾上「作为背景铺满」则铺满画布当背景。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ 预设几何 ============ -->
|
<!-- ============ 预设几何 ============ -->
|
||||||
@@ -375,7 +414,9 @@ onBeforeUnmount(() => {
|
|||||||
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
|
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="dragging" class="drop-veil" aria-hidden="true">
|
<div v-if="dragging" class="drop-veil" aria-hidden="true">
|
||||||
<div class="drop-hint">松开鼠标:把这张图作为封面背景</div>
|
<div class="drop-hint">
|
||||||
|
{{ asBackground ? "松开鼠标:作为背景铺满画布" : "松开鼠标:把图片添加为新图层" }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,6 +443,19 @@ onBeforeUnmount(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「作为背景铺满」开关 */
|
||||||
|
.mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mode input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
@@ -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 侧自验通过 ✅");
|
||||||
+16
-2
@@ -3,10 +3,24 @@ import type { BackgroundAsset } from "../types";
|
|||||||
/**
|
/**
|
||||||
* 本地上传:本地文件 → object URL。
|
* 本地上传:本地文件 → object URL。
|
||||||
*
|
*
|
||||||
* object URL 的生命周期由调用方(SourcePanel)管理:换新背景时对旧的
|
* object URL 的生命周期由调用方(SourcePanel)管理。自 T10 起拖入的图片
|
||||||
* `releaseObjectUrl`,组件卸载时兜底释放一次,避免每次换图都漏一个 blob。
|
* 是**加图层**而不是替换背景:一个会话里可能有多张存活的 blob URL
|
||||||
|
* (撤销 / 模板重载时要重新取图,不能提前 revoke),调用方负责在组件
|
||||||
|
* 卸载时统一释放,避免每拖一张都漏一个 blob。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖放/多选的取值逻辑:**只挑图片,保持原顺序**。
|
||||||
|
*
|
||||||
|
* 参数用最小结构 `{ type }` 而不是 DOM 的 `File`,是为了能在 Node 侧直接
|
||||||
|
* 断言(见 `src/sources/selfcheck.ts`)——取值只依赖 mime,不碰文件内容。
|
||||||
|
* 拖放进来的一批文件可能是图片、文档、压缩包的混合,非图片直接忽略,
|
||||||
|
* 别让一张 txt 把整批拖放打断。
|
||||||
|
*/
|
||||||
|
export function pickImageFiles<T extends { type: string }>(files: readonly T[]): T[] {
|
||||||
|
return files.filter((f) => f.type.startsWith("image/"));
|
||||||
|
}
|
||||||
|
|
||||||
/** 把本地图片文件变成 BackgroundAsset。非图片/解不出尺寸会抛错并释放 URL。 */
|
/** 把本地图片文件变成 BackgroundAsset。非图片/解不出尺寸会抛错并释放 URL。 */
|
||||||
export async function fromUpload(file: File): Promise<BackgroundAsset> {
|
export async function fromUpload(file: File): Promise<BackgroundAsset> {
|
||||||
const src = URL.createObjectURL(file);
|
const src = URL.createObjectURL(file);
|
||||||
|
|||||||
Reference in New Issue
Block a user