T10: 工具面板收进右栏折叠分组,拖放图片改为新增图层
- ToolPanels:背景/滤镜/文字/导出与模板四组可折叠分组,选中图片/文字自动展开相关组 - SourcePanel:拖放与文件选择默认作为新图层加入(addImage toBack:false 并选中), 「作为背景铺满」改为面板里的显式勾选项,不再是一切的默认行为;多张可一次全加 - 上传 object URL 改为会话内保留、卸载时统一释放(撤销与模板重载要按 src 重新取图) - 预设几何与 Unsplash 保持原「铺背景」语义,不受勾选项影响 - 新增 pickImageFiles 纯函数与 src/sources/selfcheck.ts Node 侧自检
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 【T4 — 背景来源面板】
|
||||
* 【T4→T10 — 背景来源面板】
|
||||
*
|
||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。任一来源选中的素材都调
|
||||
* `useEditor().addImage(asset.src)` 装进画布(按 cover 方式铺满并选中)。
|
||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。
|
||||
*
|
||||
* - 上传:文件选择 + 全窗口拖放。object URL 换图时释放旧的。
|
||||
* - 预设几何:种子输入(建议填标题)+ 四张实时缩略图。同种子必同图。
|
||||
* - Unsplash:没配 key 只显示提示,另外两个来源照常可用;
|
||||
* 搜索结果点一张装 `regular` 尺寸;选定即触发 download 统计(条款义务);
|
||||
* 署名不画进图,显示在面板上给复制按钮。
|
||||
* - 上传:文件选择 + 全窗口拖放。T10 起拖放/多选的默认行为是**新建一个图层**
|
||||
* (`addImage` 的 `toBack: false`:加在最上层并选中,多张一次全加);
|
||||
* 面板里的「作为背景铺满」勾上才铺满画布当背景(旧行为)。
|
||||
* object URL 全部存活到卸载时统一释放——撤销与模板重载还要重新取图。
|
||||
* - 预设几何 / Unsplash:保持 T4 验收过的「铺背景」语义,不受上面那个勾选影响。
|
||||
*
|
||||
* 只依赖 src/style.css 里的变量与 .field / .row 类,不自带颜色。
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch, type WatchStopHandle } from "vue";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
import { fromUpload, releaseObjectUrl } from "@/sources/upload";
|
||||
import { commit } from "@/editor/useHistory";
|
||||
import { fromUpload, pickImageFiles, releaseObjectUrl } from "@/sources/upload";
|
||||
import {
|
||||
GEOMETRY_KINDS,
|
||||
deriveGeometryParams,
|
||||
@@ -45,20 +45,21 @@ function errorText(err: unknown, fallback: string): string {
|
||||
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 (asset.kind === "upload") liveUploadUrls.push(asset.src); // 撤销/模板重载要重新取图
|
||||
busyLabel.value = label;
|
||||
busy.value = true;
|
||||
notice.value = "";
|
||||
try {
|
||||
// 换背景 = 替换:上一张本地上传的 object URL 不再需要,释放掉。
|
||||
if (lastUploadUrl.value && lastUploadUrl.value !== asset.src) {
|
||||
releaseObjectUrl(lastUploadUrl.value);
|
||||
}
|
||||
lastUploadUrl.value = asset.kind === "upload" ? asset.src : "";
|
||||
await addImage(asset.src);
|
||||
await addImage(asset.src, { toBack });
|
||||
lastAttribution.value = asset.attribution ?? null;
|
||||
commit();
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "图片没能装进画布");
|
||||
} finally {
|
||||
@@ -69,38 +70,71 @@ async function place(asset: BackgroundAsset, label: string) {
|
||||
/* ---------------------------------------------------------------- 本地上传 */
|
||||
|
||||
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) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
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 (!file.type.startsWith("image/")) {
|
||||
notice.value = "只能放图片文件";
|
||||
const images = pickImageFiles(files);
|
||||
if (images.length === 0) {
|
||||
notice.value = emptyMessage;
|
||||
return;
|
||||
}
|
||||
busyLabel.value = "读取图片…";
|
||||
busyLabel.value = images.length > 1 ? `载入 ${images.length} 张图片…` : "读取图片…";
|
||||
busy.value = true;
|
||||
notice.value = "";
|
||||
let asset: BackgroundAsset;
|
||||
let ok = 0;
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
asset = await fromUpload(file);
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "图片读不出来");
|
||||
for (const file of images) {
|
||||
try {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
busy.value = false;
|
||||
await place(asset, "载入图片…");
|
||||
if (ok > 0) commit();
|
||||
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);
|
||||
let dragDepth = 0;
|
||||
|
||||
@@ -133,14 +167,9 @@ function onDrop(e: DragEvent) {
|
||||
dragging.value = false;
|
||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
const file = Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
);
|
||||
if (!file) {
|
||||
notice.value = "拖进来的不是图片";
|
||||
return;
|
||||
}
|
||||
void applyFile(file);
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
void addFiles(files, "拖进来的文件里没有图片");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 预设几何 */
|
||||
@@ -183,7 +212,7 @@ function addGeometry(kind: GeometryKind) {
|
||||
size.value.width,
|
||||
size.value.height,
|
||||
);
|
||||
void place(asset, "生成几何底纹…");
|
||||
void place(asset, "生成几何底纹…", true);
|
||||
} catch (err) {
|
||||
notice.value = errorText(err, "几何底纹生成失败");
|
||||
}
|
||||
@@ -219,7 +248,7 @@ async function doSearch() {
|
||||
async function pickPhoto(photo: UnsplashPhoto) {
|
||||
if (busy.value) return;
|
||||
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
|
||||
await place(toAsset(photo), "载入 Unsplash 图…");
|
||||
await place(toAsset(photo), "载入 Unsplash 图…", true);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 署名复制 */
|
||||
@@ -266,7 +295,9 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener("drop", onDrop);
|
||||
window.clearTimeout(thumbTimer);
|
||||
stopWatch?.();
|
||||
if (lastUploadUrl.value) releaseObjectUrl(lastUploadUrl.value);
|
||||
// 本会话所有上传的 object URL 一次性释放。
|
||||
liveUploadUrls.forEach(releaseObjectUrl);
|
||||
liveUploadUrls.length = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -277,15 +308,23 @@ onBeforeUnmount(() => {
|
||||
<!-- ============ 本地上传 ============ -->
|
||||
<h3 class="block-title">本地上传</h3>
|
||||
<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>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<p class="hint">或直接把图片拖进窗口任意位置,自动铺满画布。换图时旧地址自动释放。</p>
|
||||
<p class="hint">
|
||||
也可以直接把图片拖进窗口任意位置。默认作为<strong>新图层</strong>加在最上层并选中,
|
||||
多张可一次全加;勾上「作为背景铺满」则铺满画布当背景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ============ 预设几何 ============ -->
|
||||
@@ -375,7 +414,9 @@ onBeforeUnmount(() => {
|
||||
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="dragging" class="drop-veil" aria-hidden="true">
|
||||
<div class="drop-hint">松开鼠标:把这张图作为封面背景</div>
|
||||
<div class="drop-hint">
|
||||
{{ asBackground ? "松开鼠标:作为背景铺满画布" : "松开鼠标:把图片添加为新图层" }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user