Compare commits
12
Commits
a72014e419
...
0b7f84b071
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7f84b071 | ||
|
|
0c5bcc75e9 | ||
|
|
9b82cdeb0c | ||
|
|
e86b1c7f6e | ||
|
|
46a429306a | ||
|
|
27c540837e | ||
|
|
83f27d9e90 | ||
|
|
02b9b5690e | ||
|
|
efc752f7c1 | ||
|
|
3536589514 | ||
|
|
f9e198c8b6 | ||
|
|
ae18af1bf3 |
+193
-168
@@ -1,66 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 【T7 — 顶部工具栏】
|
* 【T11 — 顶部工具栏】工具组居中,尺寸设置收进「画布编辑」下拉。
|
||||||
*
|
*
|
||||||
* 三段式(grid 1fr auto 1fr,中间严格居中):
|
* 三段式(grid 1fr auto 1fr,中间严格居中):
|
||||||
*
|
*
|
||||||
* 左 —— 工具组。选择 V / 文字 T / 矩形 R / 图片 Shift+K。
|
* 左 —— 留空(以后放文件名之类)。
|
||||||
|
* 中 —— 工具组五个按钮,这是顶栏的视觉主体:
|
||||||
|
* - 画布编辑:纯下拉(CanvasMenu),点开改预设 / 宽高 / 比例。
|
||||||
|
* - 选择 / 文字:普通按钮,切工具。
|
||||||
|
* - 形状 / 背景:本体切工具 + 右侧小角标开下拉(Figma 的形状工具样式)。
|
||||||
* 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写
|
* 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写
|
||||||
* `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。每个按钮的 tooltip
|
* `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。tooltip 用
|
||||||
* 用 `keyHintOf(commandId)` 显示键位,保证键位只在一处(commands.ts)维护。
|
* `keyHintOf(commandId)` 拼键位,保证键位只在 commands.ts 里维护。
|
||||||
* 图标从 `@/ui/icons` 取(hugeicons,构建期内联,见那个文件的说明)。
|
* 右 —— 撤销 / 重做 + 导出入口(导出按钮滚到右栏的导出面板)。
|
||||||
*
|
*
|
||||||
* 中 —— 画布尺寸:预设下拉、比例锁下拉、自定义宽高。预设表直接用
|
* 文字工具的光标:这里只往 `document.body.dataset.tool` 写当前工具,
|
||||||
* @/editor/canvasSize 的 SIZE_PRESETS / RATIOS / applyRatio。
|
* 真正的 `body[data-tool="text"] .stage { cursor: text; }` 规则在 style.css
|
||||||
*
|
* 里(body 不在本组件的 scoped 作用域内,够不到)。卸载时清掉。
|
||||||
* 右 —— 撤销 / 重做(useHistory 的 canUndo / canRedo 控制禁用态)+ 导出入口。
|
|
||||||
* 导出按钮只负责把右栏下半的滚动容器(.right-bottom,即 ToolPanels)滚到底,
|
|
||||||
* 面板本体在那里。
|
|
||||||
*/
|
*/
|
||||||
import { computed, nextTick, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, watch } from "vue";
|
||||||
import { useEditor } from "@/editor/useEditor";
|
import { useEditor } from "@/editor/useEditor";
|
||||||
import { useHistory } from "@/editor/useHistory";
|
import { useHistory } from "@/editor/useHistory";
|
||||||
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
|
|
||||||
import { runCommand } from "@/editor/commands";
|
import { runCommand } from "@/editor/commands";
|
||||||
import { keyHintOf } from "@/editor/useShortcuts";
|
import { keyHintOf } from "@/editor/useShortcuts";
|
||||||
import type { ToolId } from "@/types";
|
import { shapeMeta } from "@/editor/shapes";
|
||||||
import {
|
import {
|
||||||
IconSelect,
|
IconBackgroundTool,
|
||||||
IconText,
|
IconCanvasEdit,
|
||||||
IconRect,
|
IconChevronDown,
|
||||||
IconImage,
|
|
||||||
IconUndo,
|
|
||||||
IconRedo,
|
|
||||||
IconExport,
|
IconExport,
|
||||||
|
IconRedo,
|
||||||
|
IconSelect,
|
||||||
|
IconTextTool,
|
||||||
|
IconUndo,
|
||||||
} from "@/ui/icons";
|
} from "@/ui/icons";
|
||||||
import { openToolGroup } from "@/ui/toolGroups";
|
import { openToolGroup } from "@/ui/toolGroups";
|
||||||
|
import Dropdown from "@/components/ui/Dropdown.vue";
|
||||||
|
import CanvasMenu from "@/components/topbar/CanvasMenu.vue";
|
||||||
|
import ShapeMenu from "@/components/topbar/ShapeMenu.vue";
|
||||||
|
import BackgroundMenu from "@/components/topbar/BackgroundMenu.vue";
|
||||||
|
|
||||||
const { tool, size, setSize } = useEditor();
|
const { tool, shapeKind } = useEditor();
|
||||||
const { canUndo, canRedo } = useHistory();
|
const { canUndo, canRedo } = useHistory();
|
||||||
|
|
||||||
/* ================================================================ 工具组 */
|
/** 形状按钮的图标跟着当前形状走(和 Figma 一致)。 */
|
||||||
|
const shapeIcon = computed(() => shapeMeta(shapeKind.value).icon);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `cmd` 是命令表里的 id(字符串**原样**写在文件里,scripts 侧的 Node 断言会
|
* 形状 / 背景是「本体 + 角标」的组合:点本体切工具,点角标开下拉。
|
||||||
* 核对每个 id 都存在于 COMMANDS——拼错一个就是「按钮点了没反应」)。active 态
|
* 本体点击时若下拉正开着,顺手关掉——本体在 Dropdown 的 root 里,外壳的
|
||||||
* 由 `toolOf(cmd)` 映射回 useEditor().tool 的值来判定。
|
* 「点外面关」管不到它,这里用 slot 给的 `toggle` 在 open 时再翻一次。
|
||||||
*/
|
*/
|
||||||
const TOOLS = [
|
function pickShapeTool(open: boolean, toggle: () => void) {
|
||||||
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect },
|
runCommand("toolShape");
|
||||||
{ cmd: "toolText", label: "文字工具", icon: IconText },
|
if (open) toggle();
|
||||||
{ cmd: "toolShape", label: "形状工具", icon: IconRect },
|
}
|
||||||
{ cmd: "toolBackground", label: "背景工具", icon: IconImage },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const TOOL_OF: Record<string, ToolId> = {
|
function pickBackgroundTool(open: boolean, toggle: () => void) {
|
||||||
toolSelect: "select",
|
runCommand("toolBackground");
|
||||||
toolText: "text",
|
if (open) toggle();
|
||||||
toolShape: "shape",
|
|
||||||
toolBackground: "background",
|
|
||||||
};
|
|
||||||
|
|
||||||
function toolOf(cmd: string): ToolId {
|
|
||||||
return TOOL_OF[cmd] ?? "select";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tip(cmd: string, label: string): string {
|
function tip(cmd: string, label: string): string {
|
||||||
@@ -68,57 +66,28 @@ function tip(cmd: string, label: string): string {
|
|||||||
return k ? `${label}(${k})` : label;
|
return k ? `${label}(${k})` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================ 画布尺寸(中) */
|
/* ---------------------------------------------------------------- 光标 */
|
||||||
|
|
||||||
/** 选中预设后,任何一边宽高的改动都会把下拉弹回「自定义」。 */
|
/**
|
||||||
const presetId = ref<string>("custom");
|
* 文字 / 形状工具的鼠标指针。规则在 style.css(body 够不到 scoped),
|
||||||
/** 比例锁。改宽高时另一侧按这个比例联动;"free"(0)表示不锁。 */
|
* 这里只负责同步 data 属性。`immediate` 让初始工具也写一次。
|
||||||
const ratioId = ref<string>("free");
|
*/
|
||||||
|
|
||||||
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
|
|
||||||
|
|
||||||
const currentPreset = computed(() => SIZE_PRESETS.find((p) => p.id === presetId.value));
|
|
||||||
|
|
||||||
/** 尺寸可能被别处改掉(模板加载、别的面板),下拉常驻,要跟着真值走。 */
|
|
||||||
watch(
|
watch(
|
||||||
() => size.value,
|
tool,
|
||||||
(s) => {
|
(t) => {
|
||||||
const p = SIZE_PRESETS.find((x) => x.width === s.width && x.height === s.height);
|
document.body.dataset.tool = t;
|
||||||
presetId.value = p ? p.id : "custom";
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
function pickPreset(id: string) {
|
delete document.body.dataset.tool;
|
||||||
presetId.value = id;
|
|
||||||
const p = SIZE_PRESETS.find((s) => s.id === id);
|
|
||||||
if (p) setSize({ width: p.width, height: p.height });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动。 */
|
|
||||||
function edit(dimension: "width" | "height", raw: string) {
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n) || n < 16) return;
|
|
||||||
presetId.value = "custom";
|
|
||||||
setSize(applyRatio({ ...size.value, [dimension]: n }, ratio.value, dimension));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预设下拉里只放短名,完整「名字(宽×高)」放 title,随鼠标悬停可见。 */
|
|
||||||
function shortPresetLabel(label: string): string {
|
|
||||||
return label.split("(")[0].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetTitle = computed(() => {
|
|
||||||
const p = currentPreset.value;
|
|
||||||
return p ? `${p.label}(${p.width}×${p.height})` : "自定义尺寸";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ================================================================ 导出入口(右) */
|
/* ---------------------------------------------------------------- 导出 */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 右栏下半是滚动容器,「导出与模板」是最后一组。
|
* 右栏下半是滚动容器,「导出与模板」是最后一组。先展开再滚,否则滚到底
|
||||||
* 先展开那一组再滚——四个组默认全收,只滚不展开的话滚到底看到的还是一行标题。
|
* 看到的还是一行标题。`nextTick` 等展开内容渲染进 DOM,`scrollHeight` 才是最终高度。
|
||||||
* `nextTick` 是等展开后的内容渲染进 DOM,`scrollHeight` 才是最终高度。
|
|
||||||
*/
|
*/
|
||||||
async function scrollToExport() {
|
async function scrollToExport() {
|
||||||
openToolGroup("export");
|
openToolGroup("export");
|
||||||
@@ -131,68 +100,107 @@ async function scrollToExport() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<!-- 左:工具组 -->
|
<!-- 左:留空(以后放文件名之类) -->
|
||||||
<div class="zone left">
|
<div class="zone left"></div>
|
||||||
<button
|
|
||||||
v-for="t in TOOLS"
|
|
||||||
:key="t.cmd"
|
|
||||||
class="tbtn"
|
|
||||||
:class="{ active: toolOf(t.cmd) === tool }"
|
|
||||||
:title="tip(t.cmd, t.label)"
|
|
||||||
:aria-label="t.label"
|
|
||||||
@click="runCommand(t.cmd)"
|
|
||||||
>
|
|
||||||
<component :is="t.icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 中:画布尺寸(预设 + 比例锁 + 自定义宽高) -->
|
<!-- 中:工具组 -->
|
||||||
<div class="zone mid">
|
<div class="zone mid">
|
||||||
<select
|
<Dropdown :min-width="240">
|
||||||
class="preset"
|
<template #trigger="{ open, toggle }">
|
||||||
:value="presetId"
|
<button
|
||||||
:title="presetTitle"
|
class="tbtn"
|
||||||
aria-label="画布尺寸预设"
|
:class="{ active: open }"
|
||||||
@change="pickPreset(($event.target as HTMLSelectElement).value)"
|
title="画布编辑"
|
||||||
>
|
aria-label="画布编辑"
|
||||||
<option v-for="p in SIZE_PRESETS" :key="p.id" :value="p.id" :title="`${p.width}×${p.height}px`">
|
:aria-expanded="open"
|
||||||
{{ shortPresetLabel(p.label) }}
|
@click="toggle"
|
||||||
</option>
|
>
|
||||||
<option value="custom">自定义</option>
|
<IconCanvasEdit />
|
||||||
</select>
|
<IconChevronDown class="caret" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<CanvasMenu :close="close" />
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
<div class="dims">
|
<button
|
||||||
<input
|
class="tbtn"
|
||||||
type="number"
|
:class="{ active: tool === 'select' }"
|
||||||
min="16"
|
:title="tip('toolSelect', '选择工具')"
|
||||||
class="num"
|
aria-label="选择工具"
|
||||||
:value="size.width"
|
@click="runCommand('toolSelect')"
|
||||||
title="宽度(px)"
|
|
||||||
aria-label="画布宽度"
|
|
||||||
@change="edit('width', ($event.target as HTMLInputElement).value)"
|
|
||||||
/>
|
|
||||||
<span class="times" aria-hidden="true">×</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="16"
|
|
||||||
class="num"
|
|
||||||
:value="size.height"
|
|
||||||
title="高度(px)"
|
|
||||||
aria-label="画布高度"
|
|
||||||
@change="edit('height', ($event.target as HTMLInputElement).value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select
|
|
||||||
v-model="ratioId"
|
|
||||||
class="ratio"
|
|
||||||
title="比例锁:编辑宽高时另一侧按此比例联动"
|
|
||||||
aria-label="锁定比例"
|
|
||||||
>
|
>
|
||||||
<option v-for="r in RATIOS" :key="r.id" :value="r.id" :title="r.label">
|
<IconSelect />
|
||||||
{{ shortPresetLabel(r.label) }}
|
</button>
|
||||||
</option>
|
|
||||||
</select>
|
<button
|
||||||
|
class="tbtn"
|
||||||
|
:class="{ active: tool === 'text' }"
|
||||||
|
:title="tip('toolText', '文字工具')"
|
||||||
|
aria-label="文字工具"
|
||||||
|
@click="runCommand('toolText')"
|
||||||
|
>
|
||||||
|
<IconTextTool />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dropdown :min-width="200">
|
||||||
|
<template #trigger="{ open, toggle }">
|
||||||
|
<span class="combo">
|
||||||
|
<button
|
||||||
|
class="tbtn combo-body"
|
||||||
|
:class="{ active: tool === 'shape' || open }"
|
||||||
|
:title="tip('toolShape', '形状工具')"
|
||||||
|
aria-label="形状工具"
|
||||||
|
@click="pickShapeTool(open, toggle)"
|
||||||
|
>
|
||||||
|
<component :is="shapeIcon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tbtn combo-caret"
|
||||||
|
:class="{ active: tool === 'shape' || open }"
|
||||||
|
title="形状类型"
|
||||||
|
aria-label="选择形状类型"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<IconChevronDown />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #panel="{ close }">
|
||||||
|
<ShapeMenu :close="close" />
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<Dropdown :min-width="200">
|
||||||
|
<template #trigger="{ open, toggle }">
|
||||||
|
<span class="combo">
|
||||||
|
<button
|
||||||
|
class="tbtn combo-body"
|
||||||
|
:class="{ active: tool === 'background' || open }"
|
||||||
|
:title="tip('toolBackground', '背景工具')"
|
||||||
|
aria-label="背景工具"
|
||||||
|
@click="pickBackgroundTool(open, toggle)"
|
||||||
|
>
|
||||||
|
<IconBackgroundTool />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tbtn combo-caret"
|
||||||
|
:class="{ active: tool === 'background' || open }"
|
||||||
|
title="背景来源"
|
||||||
|
aria-label="选择背景来源"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<IconChevronDown />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #panel>
|
||||||
|
<BackgroundMenu />
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右:撤销 / 重做 / 导出 -->
|
<!-- 右:撤销 / 重做 / 导出 -->
|
||||||
@@ -218,7 +226,7 @@ async function scrollToExport() {
|
|||||||
<span class="vr" aria-hidden="true"></span>
|
<span class="vr" aria-hidden="true"></span>
|
||||||
<button
|
<button
|
||||||
class="tbtn"
|
class="tbtn"
|
||||||
title="导出(面板在右侧,点了滚过去)"
|
:title="tip('export', '导出')"
|
||||||
aria-label="导出"
|
aria-label="导出"
|
||||||
@click="scrollToExport"
|
@click="scrollToExport"
|
||||||
>
|
>
|
||||||
@@ -237,7 +245,12 @@ async function scrollToExport() {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
overflow: hidden;
|
/* **不能写 overflow: hidden。** 顶栏只有 48px 高,三个下拉面板是绝对定位在
|
||||||
|
它里面往下浮的,一裁就整块看不见(2026-09-05 踩过:按钮亮了、面板在 DOM 里、
|
||||||
|
位置也对,就是被顶栏裁没了)。工具组宽度收敛靠 .zone 的 min-width: 0,
|
||||||
|
不靠这条。 */
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone {
|
.zone {
|
||||||
@@ -263,6 +276,7 @@ async function scrollToExport() {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -289,36 +303,47 @@ async function scrollToExport() {
|
|||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 画布编辑按钮里的下角标,比工具图标小一档。 */
|
||||||
|
.tbtn .caret {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 形状 / 背景的「本体 + 角标」组合 ---- */
|
||||||
|
.combo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.combo .tbtn {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.combo .combo-body {
|
||||||
|
border-radius: var(--radius) 0 0 var(--radius);
|
||||||
|
}
|
||||||
|
.combo .combo-caret {
|
||||||
|
width: 18px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
.combo-caret .icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 组合按钮整块一起亮:hover 与 active 都作用到两个按钮,不分家。
|
||||||
|
* `.combo:hover .tbtn:not(.active)` 管非激活时的悬停(光靠单个 .tbtn:hover
|
||||||
|
* 只会亮鼠标底下那一半);`.combo .tbtn.active` 保证激活态在悬停时也不被
|
||||||
|
* .tbtn:hover 的 color 盖回 fg。 */
|
||||||
|
.combo:hover .tbtn:not(.active) {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.combo .tbtn.active {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.vr {
|
.vr {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
margin: 0 6px;
|
margin: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 画布尺寸 ---- */
|
|
||||||
.preset {
|
|
||||||
width: 112px;
|
|
||||||
}
|
|
||||||
.ratio {
|
|
||||||
width: 84px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dims {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 覆盖全局 input/select 的 width:100%,顶栏里宽度要收敛。 */
|
|
||||||
.num {
|
|
||||||
width: 58px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.times {
|
|
||||||
color: var(--fg-dim);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEditor } from "@/editor/useEditor";
|
|||||||
import { useViewport } from "@/editor/useViewport";
|
import { useViewport } from "@/editor/useViewport";
|
||||||
import { initHistory, commit } from "@/editor/useHistory";
|
import { initHistory, commit } from "@/editor/useHistory";
|
||||||
import { openContextMenu } from "@/editor/useContextMenu";
|
import { openContextMenu } from "@/editor/useContextMenu";
|
||||||
|
import { attachShapeTool } from "@/editor/useShapeTool";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」),
|
* 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」),
|
||||||
@@ -16,7 +17,7 @@ import { openContextMenu } from "@/editor/useContextMenu";
|
|||||||
*
|
*
|
||||||
* **这个文件由主控独占。**
|
* **这个文件由主控独占。**
|
||||||
*/
|
*/
|
||||||
const { mount, unmount, getCanvas, tool, addText, addRect } = useEditor();
|
const { mount, unmount, getCanvas, tool, addText } = useEditor();
|
||||||
const { zoomAt, panBy, zoomToFit } = useViewport();
|
const { zoomAt, panBy, zoomToFit } = useViewport();
|
||||||
|
|
||||||
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
||||||
@@ -26,6 +27,7 @@ let spaceDown = false;
|
|||||||
let panning = false;
|
let panning = false;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
|
let detachShapeTool: (() => void) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视口尺寸跟着容器走。
|
* 视口尺寸跟着容器走。
|
||||||
@@ -86,14 +88,12 @@ function onPointerDown(e: PointerEvent) {
|
|||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 形状工具:拖拽创建由 useShapeTool 的 attachShapeTool 接管,这里完全让开。
|
||||||
|
if (e.button === 0 && tool.value === "shape") return;
|
||||||
|
|
||||||
// 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致)
|
// 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致)
|
||||||
if (e.button === 0 && tool.value !== "select") {
|
if (e.button === 0 && tool.value !== "select") {
|
||||||
const c = getCanvas();
|
|
||||||
const p = c.getViewportPoint(e);
|
|
||||||
const scene = c.getScenePoint(e);
|
|
||||||
void p;
|
|
||||||
if (tool.value === "text") addText();
|
if (tool.value === "text") addText();
|
||||||
else if (tool.value === "shape") addRect({ x: scene.x, y: scene.y });
|
|
||||||
if (tool.value !== "background") {
|
if (tool.value !== "background") {
|
||||||
tool.value = "select";
|
tool.value = "select";
|
||||||
commit();
|
commit();
|
||||||
@@ -126,6 +126,8 @@ onMounted(() => {
|
|||||||
// 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。
|
// 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。
|
||||||
resize();
|
resize();
|
||||||
initHistory();
|
initHistory();
|
||||||
|
// 形状工具的指针交互单独挂在画布上,这里只负责装/卸。
|
||||||
|
detachShapeTool = attachShapeTool(c);
|
||||||
// 拖拽 / 缩放 / 旋转结束都算一步操作。
|
// 拖拽 / 缩放 / 旋转结束都算一步操作。
|
||||||
c.on("object:modified", () => commit());
|
c.on("object:modified", () => commit());
|
||||||
observer = new ResizeObserver(resize);
|
observer = new ResizeObserver(resize);
|
||||||
@@ -136,6 +138,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
|
detachShapeTool?.();
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
window.removeEventListener("keyup", onKeyUp);
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -1,597 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 【T4→T10 — 背景来源面板】
|
|
||||||
*
|
|
||||||
* 三个来源:本地上传、预设几何、Unsplash 搜索。
|
|
||||||
*
|
|
||||||
* - 上传:文件选择 + 全窗口拖放。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 { commit } from "@/editor/useHistory";
|
|
||||||
import { fromUpload, pickImageFiles, releaseObjectUrl } from "@/sources/upload";
|
|
||||||
import {
|
|
||||||
GEOMETRY_KINDS,
|
|
||||||
deriveGeometryParams,
|
|
||||||
geometryToDataUrl,
|
|
||||||
createGeometryAsset,
|
|
||||||
type GeometryKind,
|
|
||||||
} from "@/sources/geometry";
|
|
||||||
import {
|
|
||||||
unsplashStatus,
|
|
||||||
searchUnsplash,
|
|
||||||
trackDownload,
|
|
||||||
toAsset,
|
|
||||||
type UnsplashPhoto,
|
|
||||||
} from "@/sources/unsplash";
|
|
||||||
import type { Attribution, BackgroundAsset } from "@/types";
|
|
||||||
|
|
||||||
const { addImage, size } = useEditor();
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 通用 */
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「点一下」来源(几何缩略图 / 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 {
|
|
||||||
await addImage(asset.src, { toBack });
|
|
||||||
lastAttribution.value = asset.attribution ?? null;
|
|
||||||
commit();
|
|
||||||
} catch (err) {
|
|
||||||
notice.value = errorText(err, "图片没能装进画布");
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 本地上传 */
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
/**
|
|
||||||
* 勾上「作为背景铺满」时,上传/拖放的图片沉到最底层当背景(旧行为);
|
|
||||||
* 默认不勾——拖进来是**新建一个图层**,加在最上层并选中它。
|
|
||||||
*/
|
|
||||||
const asBackground = ref(false);
|
|
||||||
/**
|
|
||||||
* 本会话所有上传产生的 object URL。不提前 revoke:撤销与模板重载时
|
|
||||||
* fabric 还要按 src 重新取图,提前放掉会让历史/模板里的图片变空白。
|
|
||||||
*/
|
|
||||||
const liveUploadUrls: string[] = [];
|
|
||||||
|
|
||||||
function onFilePick(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
const files = input.files ? Array.from(input.files) : [];
|
|
||||||
input.value = ""; // 允许重复选同一个文件
|
|
||||||
if (files.length > 0) void addFiles(files, "选中的文件里没有图片");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传/拖放共用的入口:这一批图片**一次全加进画布**(多选或一次拖多张都支持)。
|
|
||||||
* 每张先读成本地 object URL 再进画布;全部处理完记一步历史(一次拖放 = 一步撤销)。
|
|
||||||
* 默认每张都作为新图层加在最上层并选中(最新一张在最上);
|
|
||||||
* 勾了「作为背景铺满」则每张沉到最底层当背景。
|
|
||||||
*/
|
|
||||||
async function addFiles(files: readonly File[], emptyMessage: string) {
|
|
||||||
if (busy.value) return;
|
|
||||||
const images = pickImageFiles(files);
|
|
||||||
if (images.length === 0) {
|
|
||||||
notice.value = emptyMessage;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busyLabel.value = images.length > 1 ? `载入 ${images.length} 张图片…` : "读取图片…";
|
|
||||||
busy.value = true;
|
|
||||||
notice.value = "";
|
|
||||||
let ok = 0;
|
|
||||||
const failed: string[] = [];
|
|
||||||
try {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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} 张图片(最新一张在最上层,顺序可在左栏图层里调)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 全窗口拖放:文件拖进窗口任意处,默认作为新图层加入(或按勾选铺底)。
|
|
||||||
面板常驻挂载(ToolPanels 里 v-show 控制显隐),所以在面板上挂 window 级监听
|
|
||||||
即可覆盖画布区,折叠分组不影响拖放功能。 */
|
|
||||||
const dragging = ref(false);
|
|
||||||
let dragDepth = 0;
|
|
||||||
|
|
||||||
function hasFiles(dt: DataTransfer | null): boolean {
|
|
||||||
return !!dt && dt.types.includes("Files");
|
|
||||||
}
|
|
||||||
/** 落到原生 file input 上的拖放交给 input 自己处理,别抢。 */
|
|
||||||
function onNativeInput(e: Event): boolean {
|
|
||||||
const el = e.target as Element | null;
|
|
||||||
return !!el && !!el.closest?.('input[type="file"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnter(e: DragEvent) {
|
|
||||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
dragDepth++;
|
|
||||||
dragging.value = true;
|
|
||||||
}
|
|
||||||
function onDragOver(e: DragEvent) {
|
|
||||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
|
||||||
e.preventDefault(); // 不放行就没有 drop 事件
|
|
||||||
}
|
|
||||||
function onDragLeave(e: DragEvent) {
|
|
||||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
|
||||||
dragDepth = Math.max(0, dragDepth - 1);
|
|
||||||
if (dragDepth === 0) dragging.value = false;
|
|
||||||
}
|
|
||||||
function onDrop(e: DragEvent) {
|
|
||||||
dragDepth = 0;
|
|
||||||
dragging.value = false;
|
|
||||||
if (onNativeInput(e) || !hasFiles(e.dataTransfer)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
void addFiles(files, "拖进来的文件里没有图片");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 预设几何 */
|
|
||||||
|
|
||||||
const seedText = ref("");
|
|
||||||
const THUMB_W = 320;
|
|
||||||
const thumbH = () =>
|
|
||||||
Math.max(72, Math.round((THUMB_W * size.value.height) / size.value.width));
|
|
||||||
const thumbs = reactive<Record<GeometryKind, string>>({
|
|
||||||
stripes: "",
|
|
||||||
noise: "",
|
|
||||||
grid: "",
|
|
||||||
arcs: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
let thumbTimer: number | undefined;
|
|
||||||
function refreshThumbs() {
|
|
||||||
window.clearTimeout(thumbTimer);
|
|
||||||
thumbTimer = window.setTimeout(() => {
|
|
||||||
for (const k of GEOMETRY_KINDS) {
|
|
||||||
const params = deriveGeometryParams(seedText.value, k.id, THUMB_W, thumbH());
|
|
||||||
thumbs[k.id] = geometryToDataUrl(params);
|
|
||||||
}
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomizeSeed() {
|
|
||||||
const pool = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
||||||
let s = "";
|
|
||||||
for (let i = 0; i < 6; i++) s += pool[Math.floor(Math.random() * pool.length)];
|
|
||||||
seedText.value = s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGeometry(kind: GeometryKind) {
|
|
||||||
if (busy.value) return;
|
|
||||||
try {
|
|
||||||
const asset = createGeometryAsset(
|
|
||||||
seedText.value,
|
|
||||||
kind,
|
|
||||||
size.value.width,
|
|
||||||
size.value.height,
|
|
||||||
);
|
|
||||||
void place(asset, "生成几何底纹…", true);
|
|
||||||
} catch (err) {
|
|
||||||
notice.value = errorText(err, "几何底纹生成失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- Unsplash */
|
|
||||||
|
|
||||||
const unsplashState = ref(unsplashStatus());
|
|
||||||
const query = ref("");
|
|
||||||
const results = ref<UnsplashPhoto[]>([]);
|
|
||||||
const searchedFor = ref("");
|
|
||||||
const searching = ref(false);
|
|
||||||
const searchError = ref("");
|
|
||||||
|
|
||||||
async function doSearch() {
|
|
||||||
const q = query.value.trim();
|
|
||||||
if (!q || searching.value || busy.value) return;
|
|
||||||
searchError.value = "";
|
|
||||||
searchedFor.value = "";
|
|
||||||
searching.value = true;
|
|
||||||
try {
|
|
||||||
const hits = await searchUnsplash(q);
|
|
||||||
results.value = hits;
|
|
||||||
searchedFor.value = q;
|
|
||||||
} catch (err) {
|
|
||||||
searchError.value = errorText(err, "搜索失败");
|
|
||||||
results.value = [];
|
|
||||||
} finally {
|
|
||||||
searching.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickPhoto(photo: UnsplashPhoto) {
|
|
||||||
if (busy.value) return;
|
|
||||||
trackDownload(photo); // 条款义务:用户选定即触发 download 统计
|
|
||||||
await place(toAsset(photo), "载入 Unsplash 图…", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 署名复制 */
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 生命周期 */
|
|
||||||
|
|
||||||
let stopWatch: WatchStopHandle | undefined;
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener("dragenter", onDragEnter);
|
|
||||||
window.addEventListener("dragover", onDragOver);
|
|
||||||
window.addEventListener("dragleave", onDragLeave);
|
|
||||||
window.addEventListener("drop", onDrop);
|
|
||||||
refreshThumbs();
|
|
||||||
stopWatch = watch(
|
|
||||||
[() => seedText.value, () => size.value.width, () => size.value.height],
|
|
||||||
refreshThumbs,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener("dragenter", onDragEnter);
|
|
||||||
window.removeEventListener("dragover", onDragOver);
|
|
||||||
window.removeEventListener("dragleave", onDragLeave);
|
|
||||||
window.removeEventListener("drop", onDrop);
|
|
||||||
window.clearTimeout(thumbTimer);
|
|
||||||
stopWatch?.();
|
|
||||||
// 本会话所有上传的 object URL 一次性释放。
|
|
||||||
liveUploadUrls.forEach(releaseObjectUrl);
|
|
||||||
liveUploadUrls.length = 0;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="source-panel">
|
|
||||||
<p v-if="notice" class="notice">{{ notice }}</p>
|
|
||||||
|
|
||||||
<!-- ============ 本地上传 ============ -->
|
|
||||||
<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">
|
|
||||||
也可以直接把图片拖进窗口任意位置。默认作为<strong>新图层</strong>加在最上层并选中,
|
|
||||||
多张可一次全加;勾上「作为背景铺满」则铺满画布当背景。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ 预设几何 ============ -->
|
|
||||||
<h3 class="block-title">预设几何</h3>
|
|
||||||
<div class="field">
|
|
||||||
<div class="row">
|
|
||||||
<input
|
|
||||||
v-model="seedText"
|
|
||||||
placeholder="配色种子,建议填标题"
|
|
||||||
:disabled="busy"
|
|
||||||
/>
|
|
||||||
<button class="mini" title="随机一组配色(仍是确定性的:同种子同图)" :disabled="busy" @click="randomizeSeed">
|
|
||||||
随机
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="thumbs">
|
|
||||||
<button
|
|
||||||
v-for="k in GEOMETRY_KINDS"
|
|
||||||
:key="k.id"
|
|
||||||
class="thumb"
|
|
||||||
:style="{ aspectRatio: `${THUMB_W} / ${thumbH()}` }"
|
|
||||||
:disabled="busy"
|
|
||||||
:title="k.label"
|
|
||||||
@click="addGeometry(k.id)"
|
|
||||||
>
|
|
||||||
<img :src="thumbs[k.id]" :alt="k.label" />
|
|
||||||
<span class="thumb-label">{{ k.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ Unsplash 搜索 ============ -->
|
|
||||||
<h3 class="block-title">Unsplash 搜索</h3>
|
|
||||||
<template v-if="unsplashState === 'ready'">
|
|
||||||
<div class="field 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="hint">{{ searchError }}</p>
|
|
||||||
<p v-else-if="searchedFor" class="hint">
|
|
||||||
「{{ searchedFor }}」{{ results.length }} 张,点一张装进画布
|
|
||||||
</p>
|
|
||||||
<div v-if="results.length" class="results">
|
|
||||||
<button
|
|
||||||
v-for="ph in results"
|
|
||||||
:key="ph.id"
|
|
||||||
class="shot"
|
|
||||||
:disabled="busy"
|
|
||||||
:title="ph.altDescription ?? ph.description ?? ph.authorName"
|
|
||||||
@click="pickPhoto(ph)"
|
|
||||||
>
|
|
||||||
<img :src="ph.thumbUrl" :alt="ph.altDescription ?? ph.authorName" loading="lazy" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p v-else class="hint">
|
|
||||||
未配置 Unsplash Key —— 在仓库根目录 <code>.env.local</code> 里写
|
|
||||||
<code>VITE_UNSPLASH_KEY=…</code> 后重启 dev。本地上传与预设几何不受影响。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- ============ 署名(不画进图) ============ -->
|
|
||||||
<div v-if="lastAttribution" class="field attrib">
|
|
||||||
<label>署名 · 复制进文章正文</label>
|
|
||||||
<div class="attrib-row 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>
|
|
||||||
|
|
||||||
<p v-if="busy" class="busy">{{ busyLabel }}</p>
|
|
||||||
|
|
||||||
<!-- 拖放提示层:文件悬停在窗口上时给个明确信号 -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="dragging" class="drop-veil" aria-hidden="true">
|
|
||||||
<div class="drop-hint">
|
|
||||||
{{ asBackground ? "松开鼠标:作为背景铺满画布" : "松开鼠标:把图片添加为新图层" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.source-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-title {
|
|
||||||
margin: 10px 0 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg-dim);
|
|
||||||
}
|
|
||||||
.block-title:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
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;
|
|
||||||
margin: 6px 0 0;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.hint code {
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--bg3);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.notice {
|
|
||||||
color: var(--fg-dim);
|
|
||||||
background: var(--bg3);
|
|
||||||
border-left: 2px solid var(--line);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
line-height: 1.6;
|
|
||||||
border-radius: 0 var(--radius) var(--radius) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wide {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini {
|
|
||||||
padding: 4px 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 几何缩略图:2×2 */
|
|
||||||
.thumbs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
.thumb img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.thumb-label {
|
|
||||||
position: absolute;
|
|
||||||
left: 4px;
|
|
||||||
bottom: 4px;
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.2;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unsplash 结果网格:3 列 */
|
|
||||||
.results {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 署名块 */
|
|
||||||
.attrib {
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
padding-top: 8px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.attrib-row {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.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 {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--fg-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.busy {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: var(--fg-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 全窗口拖放提示(fixed 盖住整个 app,不拦鼠标) */
|
|
||||||
.drop-veil {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.drop-hint {
|
|
||||||
background: var(--bg2);
|
|
||||||
border: 1px dashed var(--accent);
|
|
||||||
color: var(--fg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 18px;
|
|
||||||
font-size: 14px;
|
|
||||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.45);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +1,534 @@
|
|||||||
<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 {
|
||||||
|
GEOMETRY_PRESETS,
|
||||||
|
createPresetAsset,
|
||||||
|
derivePresetParams,
|
||||||
|
geometryToDataUrl,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 预设 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设底纹的**唯一真源是 `@/sources/geometry` 的 `GEOMETRY_PRESETS`**(16 条,
|
||||||
|
* 8 种图案 × 2 档色相),不在这个组件里另立一份。
|
||||||
|
*
|
||||||
|
* 配色由 `derivePresetParams` 压暗降饱和,保证封面上压白字可读——这是 t4-sources
|
||||||
|
* 就定下的约束,`sources/selfcheck.ts` 里有逐像素对比度断言看着,别改回去。
|
||||||
|
*/
|
||||||
|
const THUMB_W = 160;
|
||||||
|
const THUMB_H = 100;
|
||||||
|
|
||||||
|
/** 预设 + 它的缩略图(就是那张底纹本身,确定性生成,computed 缓存不重复渲)。 */
|
||||||
|
const presetThumbs = computed(() =>
|
||||||
|
GEOMETRY_PRESETS.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
thumb: geometryToDataUrl(derivePresetParams(p.id, THUMB_W, THUMB_H)),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function applyPreset(presetId: string) {
|
||||||
|
if (busy.value) return;
|
||||||
|
try {
|
||||||
|
const asset = createPresetAsset(presetId, 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.id)"
|
||||||
|
>
|
||||||
|
<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 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>
|
</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>
|
||||||
|
|||||||
@@ -1,7 +1,147 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 【占位】CanvasMenu 的内容由对应开发线实现。见任务书。 */
|
/**
|
||||||
|
* 画布编辑下拉:预设 / 分辨率 / 比例。
|
||||||
|
*
|
||||||
|
* 逻辑照搬旧 TopBar 中间那组,不另起一套:尺寸写回一律 `useEditor().setSize()`,
|
||||||
|
* 预设表来自 `@/editor/canvasSize` 的 `SIZE_PRESETS`,比例锁用 `applyRatio`。
|
||||||
|
* `size` 是响应式真值——模板加载、别的面板改尺寸都会反映到这里,所以下拉里的
|
||||||
|
* 显示和命中预设都 watch 真值,而不是只在打开时读一次。
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
|
||||||
|
import { IconCheck } from "@/ui/icons";
|
||||||
|
|
||||||
|
/** 选中一行后要顺手关面板,由 TopBar 通过插槽的 `close` 传进来。 */
|
||||||
|
const props = defineProps<{ close: () => void }>();
|
||||||
|
|
||||||
|
const { size, setSize } = useEditor();
|
||||||
|
|
||||||
|
/** 选中预设后,任何一边宽高的改动都会把它弹回「自定义」。 */
|
||||||
|
const presetId = ref<string>("custom");
|
||||||
|
/** 比例锁。改宽高时另一侧按这个比例联动;"free"(0)表示不锁。 */
|
||||||
|
const ratioId = ref<string>("free");
|
||||||
|
|
||||||
|
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
|
||||||
|
|
||||||
|
/** 尺寸可能被别处改掉(模板加载、别的面板),要跟着真值走。 */
|
||||||
|
watch(
|
||||||
|
() => size.value,
|
||||||
|
(s) => {
|
||||||
|
const p = SIZE_PRESETS.find((x) => x.width === s.width && x.height === s.height);
|
||||||
|
presetId.value = p ? p.id : "custom";
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function pickPreset(id: string) {
|
||||||
|
presetId.value = id;
|
||||||
|
const p = SIZE_PRESETS.find((s) => s.id === id);
|
||||||
|
if (p) setSize({ width: p.width, height: p.height });
|
||||||
|
props.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比例锁只记当前锁定,不立刻改尺寸——改宽高时才用 applyRatio 联动。 */
|
||||||
|
function pickRatio(id: string) {
|
||||||
|
ratioId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动。 */
|
||||||
|
function edit(dimension: "width" | "height", raw: string) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 16) return;
|
||||||
|
presetId.value = "custom";
|
||||||
|
setSize(applyRatio({ ...size.value, [dimension]: n }, ratio.value, dimension));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="menu-block">(CanvasMenu 待实现)</div>
|
<div class="canvas-menu">
|
||||||
|
<div class="menu-title">预设</div>
|
||||||
|
<button
|
||||||
|
v-for="p in SIZE_PRESETS"
|
||||||
|
:key="p.id"
|
||||||
|
class="menu-row"
|
||||||
|
:class="{ on: presetId === p.id }"
|
||||||
|
@click="pickPreset(p.id)"
|
||||||
|
>
|
||||||
|
<span class="name">{{ p.label }}</span>
|
||||||
|
<span class="menu-aside">{{ p.width }}×{{ p.height }}</span>
|
||||||
|
<IconCheck v-if="presetId === p.id" class="check" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
|
||||||
|
<div class="menu-title">分辨率</div>
|
||||||
|
<div class="menu-block">
|
||||||
|
<div class="dims">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="16"
|
||||||
|
class="num"
|
||||||
|
:value="size.width"
|
||||||
|
title="宽度(px)"
|
||||||
|
aria-label="画布宽度"
|
||||||
|
@change="edit('width', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<span class="times" aria-hidden="true">×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="16"
|
||||||
|
class="num"
|
||||||
|
:value="size.height"
|
||||||
|
title="高度(px)"
|
||||||
|
aria-label="画布高度"
|
||||||
|
@change="edit('height', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
|
||||||
|
<div class="menu-title">比例</div>
|
||||||
|
<button
|
||||||
|
v-for="r in RATIOS"
|
||||||
|
:key="r.id"
|
||||||
|
class="menu-row"
|
||||||
|
:class="{ on: ratioId === r.id }"
|
||||||
|
title="锁定比例:改宽高时另一侧按此联动"
|
||||||
|
@click="pickRatio(r.id)"
|
||||||
|
>
|
||||||
|
<span class="name">{{ r.label }}</span>
|
||||||
|
<IconCheck v-if="ratioId === r.id" class="check" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 名字占满剩余空间,把右侧的尺寸 / 勾推到行尾。 */
|
||||||
|
.name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中行的勾。图标走 currentColor,`.menu-row.on` 已把颜色置成 accent。 */
|
||||||
|
.check {
|
||||||
|
flex: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dims {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 【占位】ShapeMenu 的内容由对应开发线实现。见任务书。 */
|
import { useEditor } from "@/editor/useEditor";
|
||||||
|
import { runCommand } from "@/editor/commands";
|
||||||
|
import { SHAPES } from "@/editor/shapes";
|
||||||
|
import { IconCheck } from "@/ui/icons";
|
||||||
|
import type { ShapeKind } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 形状下拉的面板内容。五项 `.menu-row`,图标 + 名字来自 `SHAPES`。
|
||||||
|
* 当前形状高亮(`.on`)并在右侧显示对勾。
|
||||||
|
*
|
||||||
|
* 点一项:把 `shapeKind` 切过去,再 `runCommand("toolShape")` 切到形状工具,
|
||||||
|
* 然后关掉下拉。组件本身**不**包 Dropdown——T11 在 TopBar 里包,这里只提供
|
||||||
|
* 一列行,所以关闭通过 `close`(prop 或 `close` 事件,两种接法都兼容)交还给它。
|
||||||
|
*/
|
||||||
|
const { shapeKind } = useEditor();
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ close?: () => void }>(), { close: () => {} });
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
function pick(kind: ShapeKind) {
|
||||||
|
shapeKind.value = kind;
|
||||||
|
runCommand("toolShape");
|
||||||
|
props.close?.();
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="menu-block">(ShapeMenu 待实现)</div>
|
<button
|
||||||
|
v-for="s in SHAPES"
|
||||||
|
:key="s.kind"
|
||||||
|
type="button"
|
||||||
|
class="menu-row"
|
||||||
|
:class="{ on: shapeKind === s.kind }"
|
||||||
|
@click="pick(s.kind)"
|
||||||
|
>
|
||||||
|
<component :is="s.icon" />
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
<IconCheck v-if="shapeKind === s.kind" class="menu-aside" />
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+150
-20
@@ -1,36 +1,166 @@
|
|||||||
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` 之后
|
* 缓存的引用会指向一个已经不在画布上的旧对象。
|
||||||
* 画布上的是新对象,缓存的引用会指向一个已经不在画布上的旧对象。
|
* - **背景默认锁定(`layerLocked = true`)。**
|
||||||
* - `setBackground(asset)` 装一份素材当背景:有旧的就换掉,按 `fit` 铺,置底,
|
*
|
||||||
* `registerLayer()`,`bumpRevision()`,然后 `commit()` 记一步历史。
|
* 它铺满整个画板,不锁的话画布上就没有「空白处」了:想框选、想点空白取消选中,
|
||||||
* - `setSolid(color)` 纯色背景,用一个盖满画板的 Rect。
|
* 实际都会抓住背景把它整个拖走(2026-09-05 实测,一次误拖就把底图挪出画板)。
|
||||||
* - `clear()` 删掉背景图层。
|
*
|
||||||
* - 背景默认**不可选中**(`selectable: false`),否则点画布空白处老是选中它;
|
* 但也不能做成永久不可选——用户对背景的核心需求正是「调整 / 缩放 / 裁剪一下」。
|
||||||
* 但它要出现在图层面板里,可以在那里选中、锁定、隐藏、删除。
|
* 锁定是**可见且可撤销**的:图层面板那一行的锁图标点一下就能解开,解开后拖拽
|
||||||
* - 画板尺寸变了要重新铺(listen `useEditor().size`)。
|
* 缩放和普通图层完全一样。
|
||||||
|
*
|
||||||
|
* 走 `layerLocked` 而不是直接写 `selectable: false`,是因为锁定态由
|
||||||
|
* `applyLocked()` 统一管(selectable / evented / lockMovement… 一整套),
|
||||||
|
* 而 `registerLayer()` 每次都会照 `layerLocked` 重设一遍。自己写 `selectable`
|
||||||
|
* 会被 `object:added` 触发的那次 `registerLayer` 翻回去,只能靠「最后再设」
|
||||||
|
* 这类顺序技巧压住,脆。
|
||||||
|
* - 铺满用 `width * scaleX` 而不是 `getScaledWidth()`:后者会把默认 strokeWidth
|
||||||
|
* 也算进去(见 common 的 Fabric 陷阱 3)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
// 默认锁定,理由见文件头。registerLayer 会照这个标志跑一遍 applyLocked。
|
||||||
|
img.layerLocked = true;
|
||||||
|
fitToCanvas(img);
|
||||||
|
registerLayer(img);
|
||||||
|
c.add(img);
|
||||||
|
c.sendObjectToBack(img);
|
||||||
|
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;
|
||||||
|
rect.layerLocked = true;
|
||||||
|
registerLayer(rect);
|
||||||
|
c.add(rect);
|
||||||
|
c.sendObjectToBack(rect);
|
||||||
|
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 {},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,31 +328,6 @@ function addText(text = "标题"): Textbox {
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加一个矩形。圆角走 rx/ry,右栏的「圆角」改的就是它。 */
|
|
||||||
function addRect(at?: { x: number; y: number }): Rect {
|
|
||||||
const c = canvas.value;
|
|
||||||
if (!c) throw new Error("画布还没挂载");
|
|
||||||
const w = Math.round(size.value.width * 0.3);
|
|
||||||
const h = Math.round(size.value.height * 0.25);
|
|
||||||
const rect = new Rect({
|
|
||||||
left: at?.x ?? size.value.width / 2,
|
|
||||||
top: at?.y ?? size.value.height / 2,
|
|
||||||
originX: "center",
|
|
||||||
originY: "center",
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
fill: "#6ea8fe",
|
|
||||||
rx: 0,
|
|
||||||
ry: 0,
|
|
||||||
});
|
|
||||||
registerLayer(rect);
|
|
||||||
c.add(rect);
|
|
||||||
c.setActiveObject(rect);
|
|
||||||
c.requestRenderAll();
|
|
||||||
bumpRevision();
|
|
||||||
return rect;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvas(): Canvas {
|
function getCanvas(): Canvas {
|
||||||
const c = canvas.value;
|
const c = canvas.value;
|
||||||
if (!c) throw new Error("画布还没挂载");
|
if (!c) throw new Error("画布还没挂载");
|
||||||
@@ -380,7 +355,6 @@ export function useEditor() {
|
|||||||
setSize,
|
setSize,
|
||||||
addImage,
|
addImage,
|
||||||
addText,
|
addText,
|
||||||
addRect,
|
|
||||||
getCanvas,
|
getCanvas,
|
||||||
selectedObjects,
|
selectedObjects,
|
||||||
bumpRevision,
|
bumpRevision,
|
||||||
|
|||||||
@@ -99,14 +99,20 @@ export function deleteLayer(id: string) {
|
|||||||
/**
|
/**
|
||||||
* 把 `id` 挪到图层面板的第 `toRow` 行(0 = 最上层)。拖拽排序用。
|
* 把 `id` 挪到图层面板的第 `toRow` 行(0 = 最上层)。拖拽排序用。
|
||||||
* 内部换算成 fabric 的绘制序号:`绘制序号 = 对象总数 - 1 - 行号`。
|
* 内部换算成 fabric 的绘制序号:`绘制序号 = 对象总数 - 1 - 行号`。
|
||||||
|
*
|
||||||
|
* 背景图层**钉在最底**:它自己拖不上去,别的图层也压不到它下面。
|
||||||
|
* 「背景始终置于底部」是这个图层存在的意义,靠约束保证比靠用户自觉可靠。
|
||||||
*/
|
*/
|
||||||
export function moveLayerToRow(id: string, toRow: number) {
|
export function moveLayerToRow(id: string, toRow: number) {
|
||||||
const c = getCanvas();
|
const c = getCanvas();
|
||||||
const objs = c.getObjects();
|
const objs = c.getObjects();
|
||||||
const obj = find(id);
|
const obj = find(id);
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
const target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow));
|
if (obj.isBackground) return;
|
||||||
c.moveObjectTo(obj, target);
|
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();
|
c.requestRenderAll();
|
||||||
bumpRevision();
|
bumpRevision();
|
||||||
commit();
|
commit();
|
||||||
|
|||||||
+395
-18
@@ -1,24 +1,401 @@
|
|||||||
import type { Canvas } from "fabric";
|
import { Ellipse, Line, Path, Polygon, Rect, type Canvas, type FabricObject, type TSimplePathData } from "fabric";
|
||||||
|
import { watch } from "vue";
|
||||||
|
import { useEditor } from "./useEditor";
|
||||||
|
import { commit } from "./useHistory";
|
||||||
|
import type { ShapeKind, ToolId } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T12 待实现】形状工具的拖拽创建。
|
* 形状工具的拖拽创建。按住左键从起点拖到终点,五种形状共用一条路径。
|
||||||
*
|
*
|
||||||
* 现状(要被替换掉的):Workspace 里点一下画布就在点击处放一个固定大小的矩形。
|
* 交互模型(和 Figma 一致):
|
||||||
* 目标:按住左键从起点拖到终点松开,拖出多大就是多大,五种形状都走这一条路径。
|
* - pointerdown:在起点放一个对象(此时尺寸为 0),登记成图层。
|
||||||
|
* - pointermove:实时改对象的几何,`requestRenderAll()` + `bumpRevision()`,
|
||||||
|
* **不**在这期间 `commit()`——一次拖拽只记一步历史。
|
||||||
|
* - pointerup:距离 < 阈值当误触,改放一个默认尺寸的形状;否则选中新对象、
|
||||||
|
* 回到 select 工具、`commit()`。
|
||||||
*
|
*
|
||||||
* 契约:
|
* Shift 的语义是「新建时约束比例」:矩形/圆/多边形约束到 1:1,直线/箭头
|
||||||
* - `attachShapeTool(canvas)` 在画布挂载后调一次,返回解绑函数,卸载时调。
|
* 吸附到 45° 的倍数。这只管**新建**;已有对象缩放时的 Shift 行为由 fabric
|
||||||
* - 只在 `useEditor().tool === "shape"` 时接管指针事件;其余时间完全让开,
|
* 自己决定,这里不动。
|
||||||
* 不要影响选择/平移。
|
*
|
||||||
* - 画什么看 `useEditor().shapeKind`。
|
* 画布接管:形状工具激活期间把 `skipTargetFind` / `selection` 关掉,让 fabric
|
||||||
* - 拖拽过程中要有实时预览(把对象加进画布边拖边改,松手才 `commit()` 记一步历史)。
|
* 的 pointerdown 不选对象、不起框选(否则在已有对象上起笔会先去拖动它)。
|
||||||
* - **按住 Shift 约束成 1:1**(正方形 / 正圆 / 45° 倍数的直线和箭头)。
|
* 切回其它工具时恢复原值。watch 用默认的 pre flush,工具一换下一帧就生效,
|
||||||
* 注意这只管**新建**;已有对象缩放时的 Shift 语义保持现状,不要动。
|
* 足够赶在用户下一次点画布之前。
|
||||||
* - 松手后回到 `select` 工具并选中新对象(和 Figma 一致)。
|
|
||||||
* - 拖拽距离小于阈值(约 4px)当作误触:放一个默认尺寸的形状,不要留下一个 0×0 的东西。
|
|
||||||
* - 新对象要 `registerLayer()`,并把 `obj.shapeKind` 置成当前形状——
|
|
||||||
* 箭头和多边形在 fabric 里都是 Polygon,光看类型分不出来。
|
|
||||||
*/
|
*/
|
||||||
export function attachShapeTool(_canvas: Canvas): () => void {
|
|
||||||
return () => {};
|
const FILL = "#6ea8fe";
|
||||||
|
const STROKE = "#6ea8fe";
|
||||||
|
const STROKE_WIDTH = 4;
|
||||||
|
/** 小于这个位移(屏幕像素)当误触,放默认尺寸而不是留下 0×0 的对象。 */
|
||||||
|
const MIN_DRAG = 4;
|
||||||
|
/** 箭头三角头的轴向长度 / 半宽。头大小固定,跟杆长无关;整体缩放时才一起变。 */
|
||||||
|
const ARROW_HEAD_LEN = 12;
|
||||||
|
const ARROW_HEAD_HALF = 6;
|
||||||
|
|
||||||
|
const ed = useEditor();
|
||||||
|
|
||||||
|
interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
obj: FabricObject;
|
||||||
|
kind: ShapeKind;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
/** 误触判定的起点用屏幕坐标:4px 指的是指针在屏幕上的位移,不是场景位移(受缩放影响)。 */
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ 几何:从两点推形状 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矩形/圆/多边形的包围盒。Shift 约束成正方形:以起点为锚,边长取较长的那个分量,
|
||||||
|
* 方向跟着拖拽走(`sign(0)` 归到正方向,纯竖直拖也会得到一个正方形)。
|
||||||
|
*/
|
||||||
|
function boxFrom(a: Pt, b: Pt, constrain: boolean): Box {
|
||||||
|
let dx = b.x - a.x;
|
||||||
|
let dy = b.y - a.y;
|
||||||
|
if (constrain) {
|
||||||
|
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
dx = (dx >= 0 ? 1 : -1) * side;
|
||||||
|
dy = (dy >= 0 ? 1 : -1) * side;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minX: Math.min(a.x, a.x + dx),
|
||||||
|
minY: Math.min(a.y, a.y + dy),
|
||||||
|
maxX: Math.max(a.x, a.x + dx),
|
||||||
|
maxY: Math.max(a.y, a.y + dy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直线/箭头的终点。Shift 时把方向角吸附到最近的 45° 倍数,长度保持拖拽距离。
|
||||||
|
*/
|
||||||
|
function snappedEnd(a: Pt, b: Pt, constrain: boolean): Pt {
|
||||||
|
if (!constrain) return { x: b.x, y: b.y };
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 1e-6) return { x: a.x, y: a.y };
|
||||||
|
const deg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
const snapped = Math.round(deg / 45) * 45;
|
||||||
|
const rad = (snapped * Math.PI) / 180;
|
||||||
|
return { x: a.x + len * Math.cos(rad), y: a.y + len * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ 五种形状的构建 / 更新 */
|
||||||
|
|
||||||
|
function makeRect(box: Box): Rect {
|
||||||
|
// fabric 的默认原点是中心,显式写 left/top 原点,让 left/top 就是包围盒左上角。
|
||||||
|
return new Rect({
|
||||||
|
left: box.minX,
|
||||||
|
top: box.minY,
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
width: box.maxX - box.minX,
|
||||||
|
height: box.maxY - box.minY,
|
||||||
|
fill: FILL,
|
||||||
|
rx: 0,
|
||||||
|
ry: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRect(obj: Rect, box: Box) {
|
||||||
|
obj.set({
|
||||||
|
left: box.minX,
|
||||||
|
top: box.minY,
|
||||||
|
width: box.maxX - box.minX,
|
||||||
|
height: box.maxY - box.minY,
|
||||||
|
});
|
||||||
|
obj.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEllipse(box: Box): Ellipse {
|
||||||
|
// rx/ry 是半轴,宽高各取一半。
|
||||||
|
return new Ellipse({
|
||||||
|
left: box.minX,
|
||||||
|
top: box.minY,
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
rx: (box.maxX - box.minX) / 2,
|
||||||
|
ry: (box.maxY - box.minY) / 2,
|
||||||
|
fill: FILL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEllipse(obj: Ellipse, box: Box) {
|
||||||
|
obj.set({
|
||||||
|
left: box.minX,
|
||||||
|
top: box.minY,
|
||||||
|
rx: (box.maxX - box.minX) / 2,
|
||||||
|
ry: (box.maxY - box.minY) / 2,
|
||||||
|
});
|
||||||
|
obj.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三角形(polygon,边数固定 3):外接于包围盒的等腰三角形,顶点在上、底边在下。
|
||||||
|
* Polygon 的 points 是相对对象中心的局部坐标,left/top 是包围盒中心。
|
||||||
|
*/
|
||||||
|
function makeTriangle(box: Box): Polygon {
|
||||||
|
const w = box.maxX - box.minX;
|
||||||
|
const h = box.maxY - box.minY;
|
||||||
|
return new Polygon(
|
||||||
|
[
|
||||||
|
{ x: 0, y: -h / 2 },
|
||||||
|
{ x: -w / 2, y: h / 2 },
|
||||||
|
{ x: w / 2, y: h / 2 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
left: (box.minX + box.maxX) / 2,
|
||||||
|
top: (box.minY + box.maxY) / 2,
|
||||||
|
fill: FILL,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTriangle(obj: Polygon, box: Box) {
|
||||||
|
const w = box.maxX - box.minX;
|
||||||
|
const h = box.maxY - box.minY;
|
||||||
|
obj.set({
|
||||||
|
left: (box.minX + box.maxX) / 2,
|
||||||
|
top: (box.minY + box.maxY) / 2,
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: -h / 2 },
|
||||||
|
{ x: -w / 2, y: h / 2 },
|
||||||
|
{ x: w / 2, y: h / 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// points 改动后 width/height/pathOffset 不会自动重算(除非 exactBoundingBox),手动补一次。
|
||||||
|
obj.setDimensions();
|
||||||
|
obj.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLine(a: Pt, b: Pt): Line {
|
||||||
|
// Line 的 left/top 由 x1/y1/x2/y2 自动居中,不用(也不该)手填。
|
||||||
|
return new Line([a.x, a.y, b.x, b.y], {
|
||||||
|
stroke: STROKE,
|
||||||
|
strokeWidth: STROKE_WIDTH,
|
||||||
|
strokeLineCap: "round",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(obj: Line, a: Pt, b: Pt) {
|
||||||
|
// Line 的 _set 会在坐标变化时重算宽高并重新居中,所以直接塞坐标即可。
|
||||||
|
obj.set({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
|
||||||
|
obj.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 箭头用单个 Path 画:杆是一条开子路径(零面积,只有描边),头是一个闭合三角
|
||||||
|
* (有面积,靠 fill 填色)。
|
||||||
|
*
|
||||||
|
* 选 Path 而不是 Group 的理由:
|
||||||
|
* - 单对象 → 单包围盒 → 选中/旋转/缩放的把手都作用在这一个对象上,不用处理
|
||||||
|
* Group 的子对象在局部坐标里的重新定位。
|
||||||
|
* - `toJSON()` / `loadFromJSON()` 原样往返:它就是一个带 `shapeKind` 自定义属性的
|
||||||
|
* Path,加载时不需要任何重建逻辑。
|
||||||
|
* - 头的尺寸在**创建时**固定(和杆长无关,跟真实标注箭头一致);用户之后缩放
|
||||||
|
* 整体时,头和杆按同一比例一起变,不会出现「头被单独拉伸」的走样。
|
||||||
|
*/
|
||||||
|
function makeArrow(a: Pt, b: Pt): Path {
|
||||||
|
return new Path(arrowCommands(a, b), {
|
||||||
|
fill: STROKE,
|
||||||
|
stroke: STROKE,
|
||||||
|
strokeWidth: STROKE_WIDTH,
|
||||||
|
strokeLineCap: "round",
|
||||||
|
strokeLineJoin: "round",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArrow(obj: Path, a: Pt, b: Pt) {
|
||||||
|
obj.set({ path: arrowCommands(a, b) });
|
||||||
|
// Path 改 path 后要手动重算包围盒并重新定位到新中心。
|
||||||
|
obj.setBoundingBox(true);
|
||||||
|
obj.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成箭头的 SVG path 命令。坐标是场景坐标(Path 会按包围盒中心重新定位)。 */
|
||||||
|
function arrowCommands(a: Pt, end: Pt): TSimplePathData {
|
||||||
|
const dx = end.x - a.x;
|
||||||
|
const dy = end.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 1e-6) {
|
||||||
|
// 零长度:先占住一个退化对象,移动时再补真路径。
|
||||||
|
return [
|
||||||
|
["M", a.x, a.y],
|
||||||
|
["L", a.x, a.y],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 短到放不下头时把头长压进全长,别让头比杆还长。
|
||||||
|
const hl = Math.min(ARROW_HEAD_LEN, len);
|
||||||
|
const cos = dx / len;
|
||||||
|
const sin = dy / len;
|
||||||
|
const shaftX = a.x + (len - hl) * cos;
|
||||||
|
const shaftY = a.y + (len - hl) * sin;
|
||||||
|
// 三角底边两个顶点:底边中点在杆末端,沿杆的垂直方向各偏半个头宽。
|
||||||
|
const base1X = shaftX - ARROW_HEAD_HALF * sin;
|
||||||
|
const base1Y = shaftY + ARROW_HEAD_HALF * cos;
|
||||||
|
const base2X = shaftX + ARROW_HEAD_HALF * sin;
|
||||||
|
const base2Y = shaftY - ARROW_HEAD_HALF * cos;
|
||||||
|
return [
|
||||||
|
["M", a.x, a.y],
|
||||||
|
["L", shaftX, shaftY],
|
||||||
|
["M", base1X, base1Y],
|
||||||
|
["L", end.x, end.y],
|
||||||
|
["L", base2X, base2Y],
|
||||||
|
["Z"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ 组装 */
|
||||||
|
|
||||||
|
function createShape(kind: ShapeKind, a: Pt, b: Pt, constrain: boolean): FabricObject {
|
||||||
|
if (kind === "line") return makeLine(a, snappedEnd(a, b, constrain));
|
||||||
|
if (kind === "arrow") return makeArrow(a, snappedEnd(a, b, constrain));
|
||||||
|
const box = boxFrom(a, b, constrain);
|
||||||
|
if (kind === "rect") return makeRect(box);
|
||||||
|
if (kind === "ellipse") return makeEllipse(box);
|
||||||
|
return makeTriangle(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShape(obj: FabricObject, kind: ShapeKind, a: Pt, b: Pt, constrain: boolean) {
|
||||||
|
if (kind === "line") return updateLine(obj as Line, a, snappedEnd(a, b, constrain));
|
||||||
|
if (kind === "arrow") return updateArrow(obj as Path, a, snappedEnd(a, b, constrain));
|
||||||
|
const box = boxFrom(a, b, constrain);
|
||||||
|
if (kind === "rect") return updateRect(obj as Rect, box);
|
||||||
|
if (kind === "ellipse") return updateEllipse(obj as Ellipse, box);
|
||||||
|
return updateTriangle(obj as Polygon, box);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 误触时放一个默认尺寸的形状(宽约画板的 30%),以点击处为中心。
|
||||||
|
* 直线/箭头默认是一条水平线,其余是居中的矩形框。
|
||||||
|
*/
|
||||||
|
function placeDefault(canvas: Canvas, kind: ShapeKind, center: Pt) {
|
||||||
|
const w = Math.round(ed.size.value.width * 0.3);
|
||||||
|
const h = Math.round(ed.size.value.height * 0.25);
|
||||||
|
let obj: FabricObject;
|
||||||
|
if (kind === "line" || kind === "arrow") {
|
||||||
|
obj = createShape(kind, { x: center.x - w / 2, y: center.y }, { x: center.x + w / 2, y: center.y }, false);
|
||||||
|
} else {
|
||||||
|
obj = createShape(
|
||||||
|
kind,
|
||||||
|
{ x: center.x - w / 2, y: center.y - h / 2 },
|
||||||
|
{ x: center.x + w / 2, y: center.y + h / 2 },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
obj.shapeKind = kind;
|
||||||
|
ed.registerLayer(obj);
|
||||||
|
canvas.add(obj);
|
||||||
|
canvas.setActiveObject(obj);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
ed.bumpRevision();
|
||||||
|
commit();
|
||||||
|
ed.tool.value = "select";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ 事件接管 */
|
||||||
|
|
||||||
|
export function attachShapeTool(canvas: Canvas): () => void {
|
||||||
|
const el = canvas.upperCanvasEl;
|
||||||
|
const doc = el.ownerDocument ?? document;
|
||||||
|
let drag: DragState | null = null;
|
||||||
|
|
||||||
|
// 记下挂载时的交互开关,卸载 / 切回其它工具时原样恢复,别写死默认值。
|
||||||
|
const origSkipTargetFind = canvas.skipTargetFind;
|
||||||
|
const origSelection = canvas.selection;
|
||||||
|
const origDefaultCursor = canvas.defaultCursor;
|
||||||
|
|
||||||
|
function scenePoint(e: PointerEvent): Pt {
|
||||||
|
const p = canvas.getScenePoint(e);
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(e: PointerEvent) {
|
||||||
|
if (ed.tool.value !== "shape" || e.button !== 0) return;
|
||||||
|
const p = scenePoint(e);
|
||||||
|
const kind = ed.shapeKind.value;
|
||||||
|
const obj = createShape(kind, p, p, e.shiftKey);
|
||||||
|
// 自定义属性要在加进画布前置好:registerLayer 用 defaultLayerName 起名,靠它分五种。
|
||||||
|
obj.shapeKind = kind;
|
||||||
|
ed.registerLayer(obj);
|
||||||
|
canvas.add(obj);
|
||||||
|
drag = { obj, kind, startX: p.x, startY: p.y, startClientX: e.clientX, startClientY: e.clientY };
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
ed.bumpRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: PointerEvent) {
|
||||||
|
if (!drag) return;
|
||||||
|
const cur = scenePoint(e);
|
||||||
|
updateShape(drag.obj, drag.kind, { x: drag.startX, y: drag.startY }, cur, e.shiftKey);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
ed.bumpRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e: PointerEvent) {
|
||||||
|
if (!drag) return;
|
||||||
|
const d = drag;
|
||||||
|
drag = null;
|
||||||
|
const cur = scenePoint(e);
|
||||||
|
const dist = Math.hypot(e.clientX - d.startClientX, e.clientY - d.startClientY);
|
||||||
|
if (dist < MIN_DRAG) {
|
||||||
|
canvas.remove(d.obj);
|
||||||
|
placeDefault(canvas, d.kind, { x: d.startX, y: d.startY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 用松手点做最后一次定位,避免最后一个 move 事件比 up 慢半拍。
|
||||||
|
updateShape(d.obj, d.kind, { x: d.startX, y: d.startY }, cur, e.shiftKey);
|
||||||
|
canvas.setActiveObject(d.obj);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
ed.bumpRevision();
|
||||||
|
commit();
|
||||||
|
ed.tool.value = "select";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(tool: ToolId) {
|
||||||
|
// 形状工具期间让 fabric 完全让开:不找目标、不框选;否则在已有对象上起笔会去拖动它。
|
||||||
|
if (tool === "shape") {
|
||||||
|
canvas.skipTargetFind = true;
|
||||||
|
canvas.selection = false;
|
||||||
|
canvas.defaultCursor = "crosshair";
|
||||||
|
canvas.setCursor("crosshair");
|
||||||
|
} else {
|
||||||
|
canvas.skipTargetFind = origSkipTargetFind;
|
||||||
|
canvas.selection = origSelection;
|
||||||
|
canvas.defaultCursor = origDefaultCursor;
|
||||||
|
canvas.setCursor("default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("pointerdown", onDown);
|
||||||
|
// move/up 挂在 document 上:指针拖出画布元素也能收到,up 不丢。
|
||||||
|
doc.addEventListener("pointermove", onMove);
|
||||||
|
doc.addEventListener("pointerup", onUp);
|
||||||
|
doc.addEventListener("pointercancel", onUp);
|
||||||
|
|
||||||
|
const stopWatch = watch(() => ed.tool.value, applyMode, { immediate: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("pointerdown", onDown);
|
||||||
|
doc.removeEventListener("pointermove", onMove);
|
||||||
|
doc.removeEventListener("pointerup", onUp);
|
||||||
|
doc.removeEventListener("pointercancel", onUp);
|
||||||
|
stopWatch();
|
||||||
|
// 卸载时兜底恢复 fabric 默认交互,别把画布留在「不能选」的状态。
|
||||||
|
applyMode("select");
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+327
-4
@@ -23,6 +23,11 @@ import type { BackgroundAsset } from "../types";
|
|||||||
* 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度——
|
* 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度——
|
||||||
* 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1,
|
* 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1,
|
||||||
* 饱和度压到低饱和、偏中性。
|
* 饱和度压到低饱和、偏中性。
|
||||||
|
*
|
||||||
|
* 预设(`GEOMETRY_PRESETS`,T14 新增):新背景菜单的卡片数据源,点一下直接用,
|
||||||
|
* 不需要种子。每条预设 = 固定图案 + **人工挑过的主色相**,与 legacy 4 种不同,
|
||||||
|
* 不做哈希随机配色。颜色沿用同一套线性亮度钳制,白字一定压得住。
|
||||||
|
* 取参数走 `derivePresetParams(id, w, h)`,拿 BackgroundAsset 走 `createPresetAsset`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GeometryKind = "stripes" | "noise" | "grid" | "arcs";
|
export type GeometryKind = "stripes" | "noise" | "grid" | "arcs";
|
||||||
@@ -36,12 +41,22 @@ export const GEOMETRY_KINDS = [
|
|||||||
|
|
||||||
export type RGB = readonly [number, number, number];
|
export type RGB = readonly [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染种类 = 遗留的 4 种种子随机图案 + 预设专用扩展图案。
|
||||||
|
*
|
||||||
|
* 公开的 `GeometryKind` 保持 4 种不动:基座旧 SourcePanel 的缩略图字典
|
||||||
|
* (`Record<GeometryKind, string>`)按那 4 个键闭合,扩它就会砸掉 typecheck。
|
||||||
|
* 新增图案只供 `GEOMETRY_PRESETS` 走,不进旧 UI 的种子流程。
|
||||||
|
*/
|
||||||
|
export type RenderKind = GeometryKind | "waves" | "dots" | "horizon" | "glow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一张底纹的全部决定因素。只含派生结果,不含输入——
|
* 一张底纹的全部决定因素。只含派生结果,不含输入——
|
||||||
* 拿到这个结构就能渲染,也就能在 Node 里对它断言确定性。
|
* 拿到这个结构就能渲染,也就能在 Node 里对它断言确定性。
|
||||||
*/
|
*/
|
||||||
export interface GeometryParams {
|
export interface GeometryParams {
|
||||||
kind: GeometryKind;
|
/** 图案种类:遗留 4 种由种子派生,预设专用图案由 `derivePresetParams` 固定。 */
|
||||||
|
kind: RenderKind;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
/** 种子全文的 FNV hash(u32),像素级噪声用它做盐。 */
|
/** 种子全文的 FNV hash(u32),像素级噪声用它做盐。 */
|
||||||
@@ -103,6 +118,76 @@ export function deriveGeometryParams(
|
|||||||
return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
|
return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 预设(T14 新增) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条预设底纹:固定图案 + 人工挑过的主色相。是新背景菜单的卡片数据源
|
||||||
|
* (遍历 `GEOMETRY_PRESETS` 渲染缩略图、点一下换上),与 legacy 的
|
||||||
|
* 「种子随机配色」流程互不相干。
|
||||||
|
*
|
||||||
|
* 颜色照 legacy 同一套线性亮度钳制(base≤0.10、toneA≤0.165、toneB≤0.135),
|
||||||
|
* 白字对比度由构造保证,不需要对每张缩略图人工验——selfcheck 会逐条断言。
|
||||||
|
*/
|
||||||
|
export interface GeometryPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: RenderKind;
|
||||||
|
/** 底色的主色相(度)。人工挑的,16 张卡片的色相尽量互相错开。 */
|
||||||
|
hue: number;
|
||||||
|
/** 强调色相对底色的色相偏移(度),toneA 取整、toneB 取半(同 legacy)。 */
|
||||||
|
hueOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设注册表(16 条)。顺序即界面上卡片网格的顺序:同图案的相邻放,
|
||||||
|
* 但整体按「条纹 / 噪点 / 网格 / 同心弧 / 波纹 / 点阵 / 色带 / 光斑」展开。
|
||||||
|
*/
|
||||||
|
export const GEOMETRY_PRESETS = [
|
||||||
|
{ id: "stripe-indigo", label: "斜纹 · 靛青", kind: "stripes", hue: 220, hueOffset: 72 },
|
||||||
|
{ id: "stripe-clay", label: "斜纹 · 陶棕", kind: "stripes", hue: 24, hueOffset: 56 },
|
||||||
|
{ id: "noise-fog", label: "噪点 · 雾紫", kind: "noise", hue: 268, hueOffset: 76 },
|
||||||
|
{ id: "noise-pine", label: "噪点 · 墨绿", kind: "noise", hue: 150, hueOffset: 60 },
|
||||||
|
{ id: "grid-iris", label: "网格 · 鸢尾", kind: "grid", hue: 278, hueOffset: 84 },
|
||||||
|
{ id: "grid-abyss", label: "网格 · 深海", kind: "grid", hue: 208, hueOffset: 46 },
|
||||||
|
{ id: "arcs-rose", label: "同心弧 · 玫红", kind: "arcs", hue: 344, hueOffset: 46 },
|
||||||
|
{ id: "arcs-ember", label: "同心弧 · 余烬", kind: "arcs", hue: 20, hueOffset: 56 },
|
||||||
|
{ id: "waves-lagoon", label: "波纹 · 礁湖", kind: "waves", hue: 188, hueOffset: 84 },
|
||||||
|
{ id: "waves-denim", label: "波纹 · 牛仔", kind: "waves", hue: 236, hueOffset: 46 },
|
||||||
|
{ id: "dots-plum", label: "点阵 · 梅紫", kind: "dots", hue: 296, hueOffset: 88 },
|
||||||
|
{ id: "dots-slate", label: "点阵 · 岩蓝", kind: "dots", hue: 246, hueOffset: 64 },
|
||||||
|
{ id: "horizon-dusk", label: "色带 · 暮蓝", kind: "horizon", hue: 256, hueOffset: 80 },
|
||||||
|
{ id: "horizon-umber", label: "色带 · 赭褐", kind: "horizon", hue: 14, hueOffset: 44 },
|
||||||
|
{ id: "glow-gold", label: "光斑 · 琥珀", kind: "glow", hue: 42, hueOffset: 88 },
|
||||||
|
{ id: "glow-teal", label: "光斑 · 青瓷", kind: "glow", hue: 168, hueOffset: 96 },
|
||||||
|
] as const satisfies readonly GeometryPreset[];
|
||||||
|
|
||||||
|
const PRESET_BY_ID = new Map<string, GeometryPreset>(
|
||||||
|
GEOMETRY_PRESETS.map((p) => [p.id, p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设 → 决定一张底纹的全部参数。纯函数、确定性:同 id 同尺寸永远同图。
|
||||||
|
* 与 legacy 不同,色相不来自种子哈希而来自预设表(人工挑过),
|
||||||
|
* 只有图案的结构微调参数(带距、波长、点阵相位…)用 id 哈希取。
|
||||||
|
*/
|
||||||
|
export function derivePresetParams(
|
||||||
|
presetId: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): GeometryParams {
|
||||||
|
const preset = PRESET_BY_ID.get(presetId) ?? GEOMETRY_PRESETS[0];
|
||||||
|
const hFull = hashText(`preset:${preset.id}`);
|
||||||
|
const rand = mulberry32(hFull);
|
||||||
|
const u: number[] = [];
|
||||||
|
for (let i = 0; i < 16; i++) u.push(rand());
|
||||||
|
const off = preset.hueOffset;
|
||||||
|
// 饱和度/亮度取 legacy 分布的中间档;线性亮度钳制原样复用 → 白字对比度有保证。
|
||||||
|
const base = clampLuminance(hsl2rgb(preset.hue, 0.17, 0.24), 0.1);
|
||||||
|
const toneA = clampLuminance(hsl2rgb(preset.hue + off, 0.3, 0.44), 0.165);
|
||||||
|
const toneB = clampLuminance(hsl2rgb(preset.hue + off * 0.5, 0.22, 0.34), 0.135);
|
||||||
|
return { kind: preset.kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
|
||||||
|
}
|
||||||
|
|
||||||
/** 渲染成不透明 RGBA 像素缓冲。纯函数,不碰 DOM。 */
|
/** 渲染成不透明 RGBA 像素缓冲。纯函数,不碰 DOM。 */
|
||||||
export function renderGeometry(p: GeometryParams): {
|
export function renderGeometry(p: GeometryParams): {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -123,6 +208,18 @@ export function renderGeometry(p: GeometryParams): {
|
|||||||
case "arcs":
|
case "arcs":
|
||||||
renderArcs(p, data);
|
renderArcs(p, data);
|
||||||
break;
|
break;
|
||||||
|
case "waves":
|
||||||
|
renderWaves(p, data);
|
||||||
|
break;
|
||||||
|
case "dots":
|
||||||
|
renderDots(p, data);
|
||||||
|
break;
|
||||||
|
case "horizon":
|
||||||
|
renderHorizon(p, data);
|
||||||
|
break;
|
||||||
|
case "glow":
|
||||||
|
renderGlow(p, data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return { width: p.width, height: p.height, data };
|
return { width: p.width, height: p.height, data };
|
||||||
}
|
}
|
||||||
@@ -337,6 +434,217 @@ function renderArcs(p: GeometryParams, out: Uint8ClampedArray): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 预设专用图案(T14 新增) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 波纹:横向正弦起伏的带状条纹,隔带交替 toneA/toneB,条带边缘 1px 抗锯齿。
|
||||||
|
* 混色语义 = 向颜色逼近(sRGB 插值),任意像素亮度不会超过所选带色 →
|
||||||
|
* 白字对比度有钳制兜底,selfcheck 可以对全图断言。
|
||||||
|
*/
|
||||||
|
function renderWaves(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
const amp = Math.max(4, h * (0.03 + u[8] * 0.05)); // 波幅
|
||||||
|
// 波长:一个完整正弦周期跨 ~0.45~1.35 个屏宽,越“疏”越平静
|
||||||
|
const k = (Math.PI * 2) / Math.max(60, w * (0.45 + u[9] * 0.9));
|
||||||
|
const ph = u[10] * Math.PI * 2; // 相位
|
||||||
|
const pitch = Math.max(10, h * (0.075 + u[11] * 0.05)); // 带距
|
||||||
|
const bandHalf = Math.max(3, pitch * (0.2 + u[12] * 0.08)); // 色带半宽
|
||||||
|
const [bR, bG, bB] = p.base;
|
||||||
|
const [aR, aG, aB] = p.toneA;
|
||||||
|
const [tR, tG, tB] = p.toneB;
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
// 波面抬升/下沉后的纵坐标:同一“带”沿 x 方向上下起伏
|
||||||
|
const dy = y - Math.sin(x * k + ph) * amp;
|
||||||
|
const row = Math.floor(dy / pitch);
|
||||||
|
const local = dy - row * pitch; // 本带内位置 [0, pitch)
|
||||||
|
const d = Math.abs(local - pitch / 2);
|
||||||
|
const cov = bandHalf + 0.5 - d; // 带覆盖度,边缘 1px 抗锯齿
|
||||||
|
const useA = (row & 1) === 1;
|
||||||
|
const cR = useA ? aR : tR;
|
||||||
|
const cG = useA ? aG : tG;
|
||||||
|
const cB = useA ? aB : tB;
|
||||||
|
if (cov >= 1) {
|
||||||
|
out[i] = cR;
|
||||||
|
out[i + 1] = cG;
|
||||||
|
out[i + 2] = cB;
|
||||||
|
} else if (cov > 0) {
|
||||||
|
out[i] = bR + (cR - bR) * cov;
|
||||||
|
out[i + 1] = bG + (cG - bG) * cov;
|
||||||
|
out[i + 2] = bB + (cB - bB) * cov;
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点阵:主点隔格交替 toneA/toneB(棋盘染色),格心之间补一枚更小的副点(另一色)。
|
||||||
|
* 每个像素最多被两次 mix 影响、且都向不高于 toneA 亮度的颜色逼近。
|
||||||
|
*/
|
||||||
|
function renderDots(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
const g = Math.max(12, w * 0.042 * (0.85 + u[8] * 0.5)); // 格距(1600 宽约 57~90px,缩略图也成阵)
|
||||||
|
const offX = (u[9] - 0.5) * g; // 整幅点阵的相位偏移,避免永远居中
|
||||||
|
const offY = (u[10] - 0.5) * g;
|
||||||
|
const rMain = g * (0.19 + u[11] * 0.09);
|
||||||
|
const rSub = rMain * 0.55;
|
||||||
|
const [bR, bG, bB] = p.base;
|
||||||
|
const [aR, aG, aB] = p.toneA;
|
||||||
|
const [tR, tG, tB] = p.toneB;
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const xv = x + 0.5 - offX;
|
||||||
|
const yv = y + 0.5 - offY;
|
||||||
|
const kx = Math.round(xv / g);
|
||||||
|
const ky = Math.round(yv / g);
|
||||||
|
const dx = xv - kx * g;
|
||||||
|
const dy = yv - ky * g;
|
||||||
|
const aMain = lineCoverage(Math.sqrt(dx * dx + dy * dy), rMain);
|
||||||
|
const mainA = mod(kx + ky, 2) === 0; // 棋盘:相邻格子的主点异色
|
||||||
|
// 副点错开半格,落在主点之间的空隙里
|
||||||
|
const sx = xv - (kx + 0.5) * g;
|
||||||
|
const sy = yv - (ky + 0.5) * g;
|
||||||
|
const aSub = lineCoverage(Math.sqrt(sx * sx + sy * sy), rSub);
|
||||||
|
const mA = mainA ? aR : tR;
|
||||||
|
const mG = mainA ? aG : tG;
|
||||||
|
const mB = mainA ? aB : tB;
|
||||||
|
const sA = mainA ? tR : aR;
|
||||||
|
const sG = mainA ? tG : aG;
|
||||||
|
const sB = mainA ? tB : aB;
|
||||||
|
let r = bR;
|
||||||
|
let gg = bG;
|
||||||
|
let b = bB;
|
||||||
|
if (aMain > 0) {
|
||||||
|
r = bR + (mA - bR) * aMain;
|
||||||
|
gg = bG + (mG - bG) * aMain;
|
||||||
|
b = bB + (mB - bB) * aMain;
|
||||||
|
}
|
||||||
|
if (aSub > 0) {
|
||||||
|
r = r + (sA - r) * aSub;
|
||||||
|
gg = gg + (sG - gg) * aSub;
|
||||||
|
b = b + (sB - b) * aSub;
|
||||||
|
}
|
||||||
|
out[i++] = r | 0;
|
||||||
|
out[i++] = gg | 0;
|
||||||
|
out[i++] = b | 0;
|
||||||
|
out[i++] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分层色带:几段「扁平色带 + 平滑过渡」水平堆叠,像分层渐变的封面底色。
|
||||||
|
* 自上而下 = base → toneB → base → toneA:最亮的一段贴底边、占比最小,
|
||||||
|
* 像地平线的余光;往上都是深色段,留给白字。过渡宽 W 固定 ~5% 高,
|
||||||
|
* 防止色带相接处出现硬边。
|
||||||
|
*/
|
||||||
|
function renderHorizon(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
// 三条分界,位置由盐决定但确定性。界与界之间留足空间,过渡带互不重叠。
|
||||||
|
const b1 = h * (0.28 + u[8] * 0.1);
|
||||||
|
const b2 = h * (0.5 + u[9] * 0.12);
|
||||||
|
const b3 = h * (0.72 + u[10] * 0.1);
|
||||||
|
const W = Math.max(4, h * 0.05);
|
||||||
|
const zones: RGB[] = [p.base, p.toneB, p.base, p.toneA];
|
||||||
|
const bound = [0, b1, b2, b3, h];
|
||||||
|
const seed = p.seed32;
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
// 找出 y 落在哪一段:段色为主,向下一条段的边界靠近时向段色渐变。
|
||||||
|
// 过渡只从下段往上段推进,跨过边界时权重已到 1,段与段之间无硬边。
|
||||||
|
let zi = 3;
|
||||||
|
while (zi > 0 && y < bound[zi]) zi--;
|
||||||
|
let r = zones[zi][0];
|
||||||
|
let gg = zones[zi][1];
|
||||||
|
let b = zones[zi][2];
|
||||||
|
const up = zi < 3 ? bound[zi + 1] : h + 1;
|
||||||
|
// 最顶段(zi=3)没有上邻,不能再往 zones[4] 过渡
|
||||||
|
if (zi < 3 && y > up - 2 * W) {
|
||||||
|
const t = clamp01((y - (up - 2 * W)) / (2 * W));
|
||||||
|
const e = t * t * (3 - 2 * t); // smoothstep,避免线性过渡的顿角
|
||||||
|
const nz = zones[zi + 1];
|
||||||
|
r = r + (nz[0] - r) * e;
|
||||||
|
gg = gg + (nz[1] - gg) * e;
|
||||||
|
b = b + (nz[2] - b) * e;
|
||||||
|
}
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
// 细噪点压色带渐变的等高线(banding),幅度只有 1 档
|
||||||
|
const n = (hash2d(x, y, seed) - 0.5) * 2 * 1.2;
|
||||||
|
out[i++] = clamp255(r + n);
|
||||||
|
out[i++] = clamp255(gg + n);
|
||||||
|
out[i++] = clamp255(b + n);
|
||||||
|
out[i++] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 光斑:几枚柔边光斑(径向平方衰减)错落在深色底上。颜色只取 toneA/toneB,
|
||||||
|
* 同一像素取“最强光斑”混色而不是叠加,保证亮度不超过单枚光斑色 → 白字安全。
|
||||||
|
*/
|
||||||
|
function renderGlow(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||||
|
const { width: w, height: h, u } = p;
|
||||||
|
fill(out, w, h, p.base);
|
||||||
|
const seed = p.seed32;
|
||||||
|
const N = 3 + ((u[8] * 3) | 0); // 3~5 枚
|
||||||
|
const spots: {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
r: number;
|
||||||
|
c: RGB;
|
||||||
|
}[] = [];
|
||||||
|
const short = Math.min(w, h);
|
||||||
|
for (let n = 0; n < N; n++) {
|
||||||
|
spots.push({
|
||||||
|
// 位置/半径用 hash2d(n, 盐) 取,确定性且互不相关
|
||||||
|
cx: w * (0.16 + hash2d(n, 11, seed) * 0.68),
|
||||||
|
cy: h * (0.14 + hash2d(n, 13, seed) * 0.72),
|
||||||
|
r: short * (0.16 + hash2d(n, 17, seed) * 0.2),
|
||||||
|
c: (n & 1) === 0 ? p.toneA : p.toneB,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [bR, bG, bB] = p.base;
|
||||||
|
let i = 0;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
let best = 0; // 最强光斑的强度
|
||||||
|
let cR = bR;
|
||||||
|
let cG = bG;
|
||||||
|
let cB = bB;
|
||||||
|
for (const s of spots) {
|
||||||
|
const dx = x - s.cx;
|
||||||
|
const dy = y - s.cy;
|
||||||
|
if (dx > s.r || dx < -s.r || dy > s.r || dy < -s.r) continue; // 包围盒裁剪
|
||||||
|
const t = Math.sqrt(dx * dx + dy * dy) / s.r;
|
||||||
|
if (t >= 1) continue;
|
||||||
|
const inten = (1 - t) * (1 - t); // 平方衰减,边缘柔和
|
||||||
|
if (inten > best) {
|
||||||
|
best = inten;
|
||||||
|
cR = s.c[0];
|
||||||
|
cG = s.c[1];
|
||||||
|
cB = s.c[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best > 0) {
|
||||||
|
out[i] = (bR + (cR - bR) * best) | 0;
|
||||||
|
out[i + 1] = (bG + (cG - bG) * best) | 0;
|
||||||
|
out[i + 2] = (bB + (cB - bB) * best) | 0;
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 工具 */
|
/* ---------------------------------------------------------------- 工具 */
|
||||||
|
|
||||||
function fill(out: Uint8ClampedArray, w: number, h: number, color: RGB): void {
|
function fill(out: Uint8ClampedArray, w: number, h: number, color: RGB): void {
|
||||||
@@ -424,17 +732,22 @@ function toSrgb(lin: number): number {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WCAG 相对亮度(0-1)。 */
|
/** WCAG 相对亮度(0-1)。导出给 selfcheck 与预设白字对比度校验用。 */
|
||||||
function luminance(rgb: RGB): number {
|
export function relativeLuminance(rgb: RGB): number {
|
||||||
return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]);
|
return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 该颜色对纯白 #fff 的 WCAG 对比度(纯白相对亮度 = 1)。1.4+ 才算普通文字可读。 */
|
||||||
|
export function contrastToWhite(rgb: RGB): number {
|
||||||
|
return 1.05 / (relativeLuminance(rgb) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变),
|
* 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变),
|
||||||
* 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。
|
* 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。
|
||||||
*/
|
*/
|
||||||
function clampLuminance(rgb: RGB, maxLum: number): RGB {
|
function clampLuminance(rgb: RGB, maxLum: number): RGB {
|
||||||
const l = luminance(rgb);
|
const l = relativeLuminance(rgb);
|
||||||
if (l <= maxLum) return rgb;
|
if (l <= maxLum) return rgb;
|
||||||
const k = maxLum / l;
|
const k = maxLum / l;
|
||||||
return [
|
return [
|
||||||
@@ -469,3 +782,13 @@ export function createGeometryAsset(
|
|||||||
const params = deriveGeometryParams(seedText, kind, width, height);
|
const params = deriveGeometryParams(seedText, kind, width, height);
|
||||||
return { kind: "geometry", src: geometryToDataUrl(params), width, height };
|
return { kind: "geometry", src: geometryToDataUrl(params), width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 预设版:点一下直接换上的背景图(无种子),取参走 `derivePresetParams`。 */
|
||||||
|
export function createPresetAsset(
|
||||||
|
presetId: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): BackgroundAsset {
|
||||||
|
const params = derivePresetParams(presetId, width, height);
|
||||||
|
return { kind: "geometry", src: geometryToDataUrl(params), width, height };
|
||||||
|
}
|
||||||
|
|||||||
+34
-14
@@ -1,33 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* 背景来源汇总出口。
|
* 背景来源汇总出口。
|
||||||
*
|
*
|
||||||
* 三个来源各写一个模块,都返回/产出 `BackgroundAsset`(形状见 `@/types`):
|
* 各来源都写成一个自包含模块,统一产出 `BackgroundAsset`(形状见 `@/types`):
|
||||||
* - upload.ts 本地文件 → object URL
|
* - upload.ts 本地文件 → object URL
|
||||||
* - geometry.ts 预设几何底纹:纯函数派生+渲染,DOM 只出现在薄薄一层
|
* - geometry.ts 预设几何底纹:纯函数派生 + 渲染;`GEOMETRY_PRESETS` 是
|
||||||
* - unsplash.ts Unsplash 搜索。Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读
|
* 新背景菜单的卡片数据源(固定图案 + 人工配色,无种子),
|
||||||
|
* `GEOMETRY_KINDS` 是 legacy 的种子随机配色 4 种
|
||||||
|
* - pexels.ts / unsplash.ts 图库搜索,都实现 `PhotoProvider` 接口,
|
||||||
|
* Pexels 在前(`PHOTO_PROVIDERS` 见 providers.ts)
|
||||||
*
|
*
|
||||||
* SourcePanel 按需从子模块直接 import;这里只是把表面聚成一个稳定出口,
|
* 图库一律走 `PHOTO_PROVIDERS`,不要直接 import 某一家。
|
||||||
* 供面板以外的地方统一消费。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { fromUpload, releaseObjectUrl } from "./upload";
|
export { fromUpload, releaseObjectUrl, pickImageFiles } from "./upload";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deriveGeometryParams,
|
deriveGeometryParams,
|
||||||
|
derivePresetParams,
|
||||||
renderGeometry,
|
renderGeometry,
|
||||||
geometryToDataUrl,
|
geometryToDataUrl,
|
||||||
createGeometryAsset,
|
createGeometryAsset,
|
||||||
|
createPresetAsset,
|
||||||
GEOMETRY_KINDS,
|
GEOMETRY_KINDS,
|
||||||
|
GEOMETRY_PRESETS,
|
||||||
|
contrastToWhite,
|
||||||
|
relativeLuminance,
|
||||||
|
} from "./geometry";
|
||||||
|
export type {
|
||||||
|
GeometryParams,
|
||||||
|
GeometryKind,
|
||||||
|
RenderKind,
|
||||||
|
GeometryPreset,
|
||||||
|
RGB,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
export type { GeometryParams, GeometryKind, RGB } from "./geometry";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
unsplashStatus,
|
parseUnsplashResponse,
|
||||||
searchUnsplash,
|
unsplashProvider,
|
||||||
trackDownload,
|
|
||||||
toAsset,
|
|
||||||
UnsplashError,
|
|
||||||
} from "./unsplash";
|
} from "./unsplash";
|
||||||
export type { UnsplashStatus, UnsplashPhoto } from "./unsplash";
|
|
||||||
|
|
||||||
export type { BackgroundAsset, Attribution } from "../types";
|
export { parsePexelsResponse, pexelsProvider } from "./pexels";
|
||||||
|
|
||||||
|
export { PHOTO_PROVIDERS, providerById, PhotoProviderError } from "./providers";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BackgroundAsset,
|
||||||
|
Attribution,
|
||||||
|
StockPhoto,
|
||||||
|
PhotoProvider,
|
||||||
|
PhotoProviderId,
|
||||||
|
PhotoProviderStatus,
|
||||||
|
} from "../types";
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type {
|
||||||
|
Attribution,
|
||||||
|
BackgroundAsset,
|
||||||
|
PhotoProvider,
|
||||||
|
PhotoProviderStatus,
|
||||||
|
StockPhoto,
|
||||||
|
} from "../types";
|
||||||
|
import { PhotoProviderError } from "./errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。
|
||||||
|
*
|
||||||
|
* - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: <key>`,
|
||||||
|
* 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。
|
||||||
|
* - 共享错误类 `PhotoProviderError` 在 `errors.ts`(不依赖任何兄弟模块的叶子),
|
||||||
|
* 从那里 import,避免和 providers.ts 的注册表互相引成环。
|
||||||
|
* - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。
|
||||||
|
* - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家,
|
||||||
|
* 所以 `PHOTO_PROVIDERS` 里它排在前面(providers.ts 组装)。
|
||||||
|
* - Pexels 的署名是「建议」而非「强制」,也没有下载统计端点——
|
||||||
|
* `notifyDownload` 不实现(接口里它是可选方法,调用方无条件调即可)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 纯函数解析:按官方文档形状构造的响应样本 → 归一化 StockPhoto[]。Node 侧可断言。 */
|
||||||
|
export function parsePexelsResponse(json: unknown): StockPhoto[] {
|
||||||
|
const root = toRecord(json);
|
||||||
|
if (!root || !Array.isArray(root.photos)) return [];
|
||||||
|
const out: StockPhoto[] = [];
|
||||||
|
for (const item of root.photos) {
|
||||||
|
const photo = toStockPhoto(toRecord(item));
|
||||||
|
if (photo) out.push(photo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pexelsProvider: PhotoProvider = {
|
||||||
|
id: "pexels",
|
||||||
|
label: "Pexels",
|
||||||
|
keyUrl: "https://www.pexels.com/api/new/",
|
||||||
|
status(): PhotoProviderStatus {
|
||||||
|
return readKey() ? "ready" : "missing";
|
||||||
|
},
|
||||||
|
search: async (query, page) => {
|
||||||
|
const key = readKey();
|
||||||
|
if (!key) throw new PhotoProviderError("未配置 Pexels API Key(VITE_PEXELS_KEY)");
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
return fetchPexels(q, page, key);
|
||||||
|
},
|
||||||
|
toAsset: toStockAsset,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */
|
||||||
|
function toStockAsset(photo: StockPhoto): BackgroundAsset {
|
||||||
|
const attribution: Attribution = {
|
||||||
|
text: `Photo by ${photo.authorName} on Pexels`,
|
||||||
|
url: photo.pageUrl,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: "stock",
|
||||||
|
src: photo.fullUrl,
|
||||||
|
width: photo.width,
|
||||||
|
height: photo.height,
|
||||||
|
attribution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 内部 */
|
||||||
|
|
||||||
|
function readKey(): string {
|
||||||
|
// import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。
|
||||||
|
const meta = import.meta as { env?: { VITE_PEXELS_KEY?: unknown } };
|
||||||
|
const raw = meta.env?.VITE_PEXELS_KEY;
|
||||||
|
return typeof raw === "string" ? raw.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(v: unknown): Record<string, unknown> | null {
|
||||||
|
return v && typeof v === "object" && !Array.isArray(v)
|
||||||
|
? (v as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段照官方文档形状防御式取值;界面用不到的字段(photographer_url / avg_color /
|
||||||
|
// liked / src.portrait|landscape|tiny 等)一律不解析——不确定的字段不编造。
|
||||||
|
/** 一张 Pexels 原始对象 → StockPhoto。缺 id / 图片地址的结果没法用,返回 null。 */
|
||||||
|
function toStockPhoto(raw: Record<string, unknown> | null): StockPhoto | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const id = typeof raw.id === "number" ? String(raw.id) : undefined;
|
||||||
|
const url = typeof raw.url === "string" ? raw.url : undefined;
|
||||||
|
const photographer = typeof raw.photographer === "string" ? raw.photographer : "";
|
||||||
|
const width = typeof raw.width === "number" ? raw.width : 0;
|
||||||
|
const height = typeof raw.height === "number" ? raw.height : 0;
|
||||||
|
const alt = typeof raw.alt === "string" ? raw.alt.trim() || null : null;
|
||||||
|
const src = toRecord(raw.src);
|
||||||
|
const s = (k: string): string => {
|
||||||
|
const v = src?.[k];
|
||||||
|
return typeof v === "string" ? v : "";
|
||||||
|
};
|
||||||
|
if (!id || !url) return null;
|
||||||
|
const original = s("original");
|
||||||
|
const large2x = s("large2x");
|
||||||
|
const large = s("large");
|
||||||
|
const medium = s("medium");
|
||||||
|
const small = s("small");
|
||||||
|
if (!large2x && !large && !original) return null; // 一张图连大图档都没有,没法装进画布
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
provider: "pexels",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
thumbUrl: medium || small || large2x || large,
|
||||||
|
// 装进画布用约 1600px 的档,不要原图(原图动辄 6000px)。
|
||||||
|
// Pexels 的档位是宽档:large2x ≈ 1880px,large ≈ 940px,按 brief 优先 large2x。
|
||||||
|
fullUrl: large2x || large || original,
|
||||||
|
pageUrl: url,
|
||||||
|
authorName: photographer || "Pexels 用户",
|
||||||
|
alt,
|
||||||
|
// Pexels 没有下载统计端点,downloadLocation 不填。
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际请求。错误路径统一抛 PhotoProviderError。 */
|
||||||
|
async function fetchPexels(query: string, page: number, key: string): Promise<StockPhoto[]> {
|
||||||
|
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&page=${page}&per_page=15`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { Authorization: key } });
|
||||||
|
} catch {
|
||||||
|
throw new PhotoProviderError("连不上 Pexels(网络或跨域限制)");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) throw new PhotoProviderError("Pexels API Key 无效,检查 .env.local");
|
||||||
|
if (res.status === 403 || res.status === 429)
|
||||||
|
throw new PhotoProviderError("Pexels 请求被限流,等一会儿再试");
|
||||||
|
// 非预期状态:响应体里可能有官方 error 字段,能读就读出来给用户看。
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const body = toRecord((await res.json()) as unknown);
|
||||||
|
detail = typeof body?.error === "string" ? `:${body.error}` : "";
|
||||||
|
} catch {
|
||||||
|
/* 读不出就算了,用状态码兜底 */
|
||||||
|
}
|
||||||
|
throw new PhotoProviderError(`Pexels 返回 ${res.status}${detail}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
return parsePexelsResponse(data);
|
||||||
|
}
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { PhotoProvider, PhotoProviderId } from "@/types";
|
import type { PhotoProvider, PhotoProviderId } from "@/types";
|
||||||
|
import { pexelsProvider } from "./pexels";
|
||||||
|
import { unsplashProvider } from "./unsplash";
|
||||||
|
|
||||||
/** 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话,不让路径崩掉。 */
|
// 共享错误类定义在 unsplash.ts(依赖最少的叶子模块),这里 re-export 保持桩文件
|
||||||
export class PhotoProviderError extends Error {
|
// 原本的出口路径;pexels.ts 也直接 import 它,避免 providers ↔ 各家模块引成环。
|
||||||
constructor(message: string) {
|
export { PhotoProviderError } from "./errors";
|
||||||
super(message);
|
|
||||||
this.name = "PhotoProviderError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 【T14 待实现】图库注册表。
|
* 图库注册表。
|
||||||
*
|
*
|
||||||
* 顺序即界面上标签页的顺序。**Pexels 在前**:Unsplash demo key 每小时只有 50 次
|
* 顺序即界面上标签页的顺序。**Pexels 在前**:Unsplash demo key 每小时只有 50 次
|
||||||
* 请求,开发期点几下就没了;Pexels 是每小时 200 次、每月 2 万次。
|
* 请求,开发期点几下就没了;Pexels 是每小时 200 次、每月 2 万次。
|
||||||
|
* 界面只认 `PhotoProvider` 接口遍历这里,不写 `if (provider === "pexels")`。
|
||||||
*/
|
*/
|
||||||
export const PHOTO_PROVIDERS: PhotoProvider[] = [];
|
export const PHOTO_PROVIDERS: readonly PhotoProvider[] = [
|
||||||
|
pexelsProvider,
|
||||||
|
unsplashProvider,
|
||||||
|
];
|
||||||
|
|
||||||
export function providerById(id: PhotoProviderId): PhotoProvider | undefined {
|
export function providerById(id: PhotoProviderId): PhotoProvider | undefined {
|
||||||
return PHOTO_PROVIDERS.find((p) => p.id === id);
|
return PHOTO_PROVIDERS.find((p) => p.id === id);
|
||||||
|
|||||||
+251
-15
@@ -1,13 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* 【T10】Node 侧自验:多文件拖放的取值逻辑。运行:`bun src/sources/selfcheck.ts`
|
* Node 侧自验。运行:`bun src/sources/selfcheck.ts`
|
||||||
*
|
*
|
||||||
* 拖放一次可能给一批混合文件(图片、文档、压缩包…),这条线把「从里面
|
* 【T10】多文件拖放的取值逻辑:拖放一次可能给一批混合文件(图片、文档、压缩包…),
|
||||||
* 挑出图片、保持相对顺序」抽成纯函数 `pickImageFiles`(src/sources/upload.ts),
|
* 这条线把「从里面挑出图片、保持相对顺序」抽成纯函数 `pickImageFiles`
|
||||||
* 这里断言它:只挑图片、顺序不变、空 type / 非图片全部排除。取值逻辑不
|
* (src/sources/upload.ts),这里断言它。取值不依赖浏览器,Node 里就能验;
|
||||||
* 依赖浏览器,Node 里就能验;真正把图拖进窗口的行为由主控用 Chrome 验。
|
* 真正把图拖进窗口的行为由主控用 Chrome 验。
|
||||||
|
*
|
||||||
|
* 【T14】图库接入(Pexels / Unsplash)与几何预设:
|
||||||
|
* - 两家 API 响应 → StockPhoto 的解析是纯函数,这里喂**手写的、按官方文档形状
|
||||||
|
* 构造的**响应样本断言(主控还没拿到 key,跑不了真实请求)。
|
||||||
|
* - 没配 key 时两家 provider 的 `status()` 必须是 "missing"、`search()` 抛的
|
||||||
|
* 必须是共享的 `PhotoProviderError`(模块静默缺失,绝不顶层抛异常白屏)。
|
||||||
|
* - `GEOMETRY_PRESETS` ≥ 12 条、id 唯一;每条底色对白字 ≥ ~7:1、
|
||||||
|
* 强调色也压得住;新增的 4 个图案家族(波纹/点阵/色带/光斑)逐像素断言 ≥ 4.5:1。
|
||||||
|
* 注意:跑这段时若 `.env.local` 里真配了 key,status/search 两组断言会翻——
|
||||||
|
* 它测的就是「没 key」这条路。
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { pickImageFiles } from "./upload";
|
import { pickImageFiles } from "./upload";
|
||||||
|
import { parsePexelsResponse, pexelsProvider } from "./pexels";
|
||||||
|
import {
|
||||||
|
parseUnsplashResponse,
|
||||||
|
unsplashProvider,
|
||||||
|
} from "./unsplash";
|
||||||
|
import { PHOTO_PROVIDERS, PhotoProviderError, providerById } from "./providers";
|
||||||
|
import {
|
||||||
|
GEOMETRY_PRESETS,
|
||||||
|
derivePresetParams,
|
||||||
|
renderGeometry,
|
||||||
|
contrastToWhite,
|
||||||
|
} from "./geometry";
|
||||||
|
|
||||||
|
/* ---------------- 0. T10:拖放取值(原保留) ---------------- */
|
||||||
|
|
||||||
/** 模拟 File 的最小形状:拖放取值只用到 mime type。 */
|
/** 模拟 File 的最小形状:拖放取值只用到 mime type。 */
|
||||||
interface FakeFile {
|
interface FakeFile {
|
||||||
@@ -15,8 +39,6 @@ interface FakeFile {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 1. 混合文件:只挑图片,顺序不变 ---------------- */
|
|
||||||
|
|
||||||
const mixed: FakeFile[] = [
|
const mixed: FakeFile[] = [
|
||||||
{ name: "封面主图.png", type: "image/png" },
|
{ name: "封面主图.png", type: "image/png" },
|
||||||
{ name: "说明.txt", type: "text/plain" },
|
{ name: "说明.txt", type: "text/plain" },
|
||||||
@@ -34,15 +56,11 @@ assert.deepEqual(
|
|||||||
"只挑出图片,且相对顺序与拖入时一致",
|
"只挑出图片,且相对顺序与拖入时一致",
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✓ 混合文件:4 张图片被挑出,txt/zip/json 被剔除,顺序不变");
|
|
||||||
|
|
||||||
/* ---------------- 2. 边界:没有图片 / 空 type / 空数组 ---------------- */
|
|
||||||
|
|
||||||
assert.equal(pickImageFiles([]).length, 0, "空拖放 → 空结果");
|
assert.equal(pickImageFiles([]).length, 0, "空拖放 → 空结果");
|
||||||
|
|
||||||
const noImage: FakeFile[] = [
|
const noImage: FakeFile[] = [
|
||||||
{ name: "笔记.txt", type: "text/plain" },
|
{ name: "笔记.txt", type: "text/plain" },
|
||||||
{ name: "无扩展名", type: "" }, // 系统认不出 mime 的也当非图片
|
{ name: "无扩展名", type: "" },
|
||||||
{ name: "包.zip", type: "application/zip" },
|
{ name: "包.zip", type: "application/zip" },
|
||||||
];
|
];
|
||||||
assert.equal(pickImageFiles(noImage).length, 0, "不含图片时返回空数组,不报错");
|
assert.equal(pickImageFiles(noImage).length, 0, "不含图片时返回空数组,不报错");
|
||||||
@@ -54,8 +72,6 @@ assert.deepEqual(
|
|||||||
"type 为空的文件不进(不猜后缀)",
|
"type 为空的文件不进(不猜后缀)",
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ---------------- 3. 全是图片:原样全收,顺序不变 ---------------- */
|
|
||||||
|
|
||||||
const allImage: FakeFile[] = [
|
const allImage: FakeFile[] = [
|
||||||
{ name: "1.webp", type: "image/webp" },
|
{ name: "1.webp", type: "image/webp" },
|
||||||
{ name: "2.png", type: "image/png" },
|
{ name: "2.png", type: "image/png" },
|
||||||
@@ -63,6 +79,226 @@ const allImage: FakeFile[] = [
|
|||||||
];
|
];
|
||||||
assert.deepEqual(pickImageFiles(allImage), allImage, "全是图片时顺序原样返回");
|
assert.deepEqual(pickImageFiles(allImage), allImage, "全是图片时顺序原样返回");
|
||||||
|
|
||||||
console.log("✓ 边界:空 / 全非图片 / 空 type / 全图片,四种情况取值都正确");
|
console.log("✓ T10 拖放取值:混合/空/非图片/全图片四组断言通过");
|
||||||
|
|
||||||
|
/* ---------------- 1. T14:Pexels 响应解析(手写官方形状样本) ---------------- */
|
||||||
|
|
||||||
|
interface PexelsSamplePhoto {
|
||||||
|
id: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
url?: string;
|
||||||
|
photographer?: string;
|
||||||
|
src?: {
|
||||||
|
original?: string;
|
||||||
|
large2x?: string;
|
||||||
|
large?: string;
|
||||||
|
medium?: string;
|
||||||
|
small?: string;
|
||||||
|
};
|
||||||
|
/** 老照片可能没有 alt(Pexels 2021 年后才稳定返回)。 */
|
||||||
|
alt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PEXELS_SAMPLE: { total_results: number; photos: PexelsSamplePhoto[] } = {
|
||||||
|
total_results: 2,
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
id: 2014422,
|
||||||
|
width: 3000,
|
||||||
|
height: 2000,
|
||||||
|
url: "https://www.pexels.com/photo/brown-and-gray-mountains-2014422/",
|
||||||
|
photographer: "Lukas",
|
||||||
|
src: {
|
||||||
|
original: "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg",
|
||||||
|
large2x:
|
||||||
|
"https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
|
||||||
|
large:
|
||||||
|
"https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
|
||||||
|
medium:
|
||||||
|
"https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=350&w=350",
|
||||||
|
small:
|
||||||
|
"https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=130&w=130",
|
||||||
|
},
|
||||||
|
alt: "Brown and gray mountains at daytime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 老照片:没有 alt、photographer 空串、缺 medium/small 档——都要能兜住
|
||||||
|
id: 3584578,
|
||||||
|
width: 5000,
|
||||||
|
height: 3333,
|
||||||
|
url: "https://www.pexels.com/photo/3584578/",
|
||||||
|
photographer: "",
|
||||||
|
src: {
|
||||||
|
large2x:
|
||||||
|
"https://images.pexels.com/photos/3584578/pexels-photo-3584578.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
|
||||||
|
large:
|
||||||
|
"https://images.pexels.com/photos/3584578/pexels-photo-3584578.jpeg?auto=compress&cs=tinysrgb&h=650&w=940",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 结构烂到没法用的条目(没 url / 没 src)应被跳过而不是抛异常
|
||||||
|
{ id: 999, width: 10, height: 10 },
|
||||||
|
{ id: 888, photographer: "Ghost" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pexelsParsed = parsePexelsResponse(PEXELS_SAMPLE);
|
||||||
|
assert.equal(pexelsParsed.length, 2, "能用条目全解析,烂条目被跳过不抛");
|
||||||
|
const px0 = pexelsParsed[0];
|
||||||
|
assert.equal(px0.provider, "pexels");
|
||||||
|
assert.equal(px0.id, "2014422", "数字 id 归一成字符串");
|
||||||
|
assert.equal(px0.authorName, "Lukas");
|
||||||
|
assert.equal(px0.alt, "Brown and gray mountains at daytime");
|
||||||
|
assert.equal(
|
||||||
|
px0.fullUrl,
|
||||||
|
PEXELS_SAMPLE.photos[0].src!.large2x,
|
||||||
|
"取大图档 large2x(≈1880px),不取原图",
|
||||||
|
);
|
||||||
|
assert.equal(px0.thumbUrl, PEXELS_SAMPLE.photos[0].src!.medium);
|
||||||
|
assert.equal(px0.pageUrl, PEXELS_SAMPLE.photos[0].url);
|
||||||
|
assert.equal(px0.width, 3000);
|
||||||
|
assert.equal(px0.height, 2000);
|
||||||
|
assert.equal(px0.downloadLocation, undefined, "Pexels 没有下载统计端点,不填");
|
||||||
|
|
||||||
|
const px1 = pexelsParsed[1];
|
||||||
|
assert.equal(px1.alt, null, "老图没有 alt → null 而不是 undefined");
|
||||||
|
assert.equal(px1.authorName, "Pexels 用户", "photographer 空串要兜底");
|
||||||
|
assert.ok(px1.fullUrl.endsWith("w=940"), "缺 medium 档不影响大图档选择");
|
||||||
|
|
||||||
|
console.log("✓ Pexels 解析:官方形状样本 → StockPhoto,烂条目跳过、缺字段兜底");
|
||||||
|
|
||||||
|
/* ---------------- 2. T14:Unsplash 响应解析 ---------------- */
|
||||||
|
|
||||||
|
interface UnsplashSamplePhoto {
|
||||||
|
id: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
description?: string;
|
||||||
|
alt_description?: string | null;
|
||||||
|
urls?: { raw?: string; regular?: string; small?: string; thumb?: string };
|
||||||
|
links?: { html?: string; download_location?: string };
|
||||||
|
user?: { name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSPLASH_SAMPLE: { results: UnsplashSamplePhoto[] } = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "abc123",
|
||||||
|
width: 4000,
|
||||||
|
height: 6000,
|
||||||
|
description: "A misty forest at dawn",
|
||||||
|
alt_description: "pine trees near lake",
|
||||||
|
urls: {
|
||||||
|
raw: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3",
|
||||||
|
regular: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=1080",
|
||||||
|
small: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=400",
|
||||||
|
thumb: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=200",
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
html: "https://unsplash.com/photos/abc123",
|
||||||
|
download_location: "https://api.unsplash.com/photos/abc123/download?ixid=Mnwx",
|
||||||
|
},
|
||||||
|
user: { name: "Jane Doe" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 只有 description、没有 raw/作者——退到 regular + 兜底作者
|
||||||
|
id: "def456",
|
||||||
|
width: 2000,
|
||||||
|
height: 1000,
|
||||||
|
description: "coast line",
|
||||||
|
alt_description: "",
|
||||||
|
urls: {
|
||||||
|
regular: "https://images.unsplash.com/photo-2?ixid=Mnwy&w=1080",
|
||||||
|
},
|
||||||
|
links: { html: "https://unsplash.com/photos/def456" },
|
||||||
|
user: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsplashParsed = parseUnsplashResponse(UNSPLASH_SAMPLE);
|
||||||
|
assert.equal(unsplashParsed.length, 2);
|
||||||
|
const us0 = unsplashParsed[0];
|
||||||
|
assert.equal(us0.provider, "unsplash");
|
||||||
|
assert.equal(us0.id, "abc123");
|
||||||
|
assert.equal(us0.alt, "pine trees near lake", "alt_description 优先于 description");
|
||||||
|
assert.equal(us0.authorName, "Jane Doe");
|
||||||
|
assert.ok(us0.fullUrl.includes("w=1600"), "raw 追加 &w=1600(≈1600px 长边),不取原图");
|
||||||
|
assert.ok(!us0.fullUrl.includes("w=1080"), "raw 存在时用 raw+w1600 而不是 regular");
|
||||||
|
assert.equal(
|
||||||
|
us0.pageUrl,
|
||||||
|
"https://unsplash.com/photos/abc123?utm_source=cover-studio&utm_medium=referral",
|
||||||
|
"署名链接带 utm_source",
|
||||||
|
);
|
||||||
|
assert.equal(us0.downloadLocation, "https://api.unsplash.com/photos/abc123/download?ixid=Mnwx");
|
||||||
|
assert.equal(us0.thumbUrl, UNSPLASH_SAMPLE.results[0].urls!.small, "网格缩略图取 small");
|
||||||
|
|
||||||
|
const us1 = unsplashParsed[1];
|
||||||
|
assert.equal(
|
||||||
|
us1.fullUrl,
|
||||||
|
"https://images.unsplash.com/photo-2?ixid=Mnwy&w=1080",
|
||||||
|
"没有 raw 时退到 regular",
|
||||||
|
);
|
||||||
|
assert.equal(us1.alt, "coast line", "alt 为空串时退到 description");
|
||||||
|
assert.equal(us1.authorName, "Unsplash 用户", "user.name 缺失要兜底");
|
||||||
|
|
||||||
|
console.log("✓ Unsplash 解析:官方形状样本 → StockPhoto,raw/regular 档位选择正确");
|
||||||
|
|
||||||
|
/* ---------------- 3. T14:注册表顺序 + 没配 key 的缺失态 ---------------- */
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
PHOTO_PROVIDERS.map((p) => p.id),
|
||||||
|
["pexels", "unsplash"],
|
||||||
|
"PHOTO_PROVIDERS 顺序 = 界面标签页顺序,Pexels 在前(dev 速率高)",
|
||||||
|
);
|
||||||
|
assert.equal(providerById("pexels"), pexelsProvider);
|
||||||
|
assert.equal(providerById("unsplash"), unsplashProvider);
|
||||||
|
|
||||||
|
for (const p of PHOTO_PROVIDERS) {
|
||||||
|
assert.equal(p.status(), "missing", `${p.id} 没配 key 时应是 missing(模块静默,不顶层抛)`);
|
||||||
|
await assert.rejects(
|
||||||
|
p.search("forest", 1),
|
||||||
|
(err) => err instanceof PhotoProviderError,
|
||||||
|
`${p.id}.search() 没配 key 时抛 PhotoProviderError,而不是别的`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("✓ 注册表与缺失态:Pexels 在前,没 key 时两家 status=missing、search 抛 PhotoProviderError");
|
||||||
|
|
||||||
|
/* ---------------- 4. T14:几何预设的数量与白字对比度 ---------------- */
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
GEOMETRY_PRESETS.length >= 12,
|
||||||
|
`预设要 ≥ 12 条(现在是 ${GEOMETRY_PRESETS.length})`,
|
||||||
|
);
|
||||||
|
const ids = new Set(GEOMETRY_PRESETS.map((p) => p.id));
|
||||||
|
assert.equal(ids.size, GEOMETRY_PRESETS.length, "预设 id 不能重复");
|
||||||
|
|
||||||
|
// 新加的 4 个图案家族渲染语义是「向 ≤toneA 亮度的颜色混」,可对全图逐像素断言;
|
||||||
|
// legacy 4 种(斜纹/噪点/网格/同心弧)的渲染器没动,其中网格线是 legacy 的加色
|
||||||
|
// 语义(细线会亮过单色),只断言三档色板本身——那几档决定大面积底色与白字对比。
|
||||||
|
const NEW_FAMILIES = new Set(["waves", "dots", "horizon", "glow"]);
|
||||||
|
|
||||||
|
for (const preset of GEOMETRY_PRESETS) {
|
||||||
|
const p = derivePresetParams(preset.id, 64, 36);
|
||||||
|
const name = `${preset.id}(${preset.kind})`;
|
||||||
|
const baseC = contrastToWhite(p.base);
|
||||||
|
const toneAC = contrastToWhite(p.toneA);
|
||||||
|
const toneBC = contrastToWhite(p.toneB);
|
||||||
|
assert.ok(baseC >= 6.8, `${name} 底色对白字对比度 ${baseC.toFixed(2)} 应 ≥ ~7:1`);
|
||||||
|
assert.ok(toneAC >= 4.7, `${name} 主强调色 ${toneAC.toFixed(2)} 应 ≥ ~4.9:1`);
|
||||||
|
assert.ok(toneBC >= 5.5, `${name} 副强调色 ${toneBC.toFixed(2)} 应 ≥ ~5.7:1`);
|
||||||
|
|
||||||
|
if (NEW_FAMILIES.has(preset.kind)) {
|
||||||
|
const { data } = renderGeometry(p);
|
||||||
|
let min = Infinity;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const c = contrastToWhite([data[i], data[i + 1], data[i + 2]]);
|
||||||
|
if (c < min) min = c;
|
||||||
|
}
|
||||||
|
assert.ok(min >= 4.5, `${name} 渲染全图最亮像素对白字 ${min.toFixed(2)}:1 也应 ≥ 4.5`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ 几何预设:${GEOMETRY_PRESETS.length} 条,色板白字压得住(新家族逐像素 ≥4.5:1)`);
|
||||||
|
|
||||||
console.log("\n全部 Node 侧自验通过 ✅");
|
console.log("\n全部 Node 侧自验通过 ✅");
|
||||||
|
|||||||
+120
-89
@@ -1,99 +1,67 @@
|
|||||||
import type { Attribution, BackgroundAsset } from "../types";
|
import type {
|
||||||
|
Attribution,
|
||||||
|
BackgroundAsset,
|
||||||
|
PhotoProvider,
|
||||||
|
StockPhoto,
|
||||||
|
} from "../types";
|
||||||
|
import { PhotoProviderError } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsplash 搜索。
|
* Unsplash 搜索,归一到 `PhotoProvider` 接口——界面只认接口,不写
|
||||||
|
* `if (provider === "unsplash")`。
|
||||||
*
|
*
|
||||||
* - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`(已 gitignore)。
|
* - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`。
|
||||||
* - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示,
|
* - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示,
|
||||||
* 上传与预设几何不受影响。所有对外函数都先查 `unsplashStatus()`。
|
* 上传与预设几何不受影响。所有对外搜索先查 key,缺了就抛 `PhotoProviderError`。
|
||||||
* - 署名按用户要求**不画进图里**:`toAsset()` 把 attribution 填进
|
* - 署名按用户要求**不画进图里**:`toAsset` 把 attribution 填进
|
||||||
* `BackgroundAsset.attribution`,由面板显示成可复制文本,用户贴进文章正文。
|
* `BackgroundAsset.attribution`,由面板显示成可复制文本,用户贴进文章正文。
|
||||||
* - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。
|
* - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type UnsplashStatus = "missing" | "ready";
|
/* ------------------------------------------------------------------ PhotoProvider */
|
||||||
|
|
||||||
/** 网络/参数层面的可预期错误,面板据此显示一句话,不让整条路径崩掉。 */
|
/** 纯函数解析:官方响应样本 → 归一化 StockPhoto[]。selfcheck 在 Node 里喂样本断言。 */
|
||||||
export class UnsplashError extends Error {
|
export function parseUnsplashResponse(json: unknown): StockPhoto[] {
|
||||||
constructor(message: string) {
|
const root = toRecord(json);
|
||||||
super(message);
|
if (!root || !Array.isArray(root.results)) return [];
|
||||||
this.name = "UnsplashError";
|
// 与 provider.search 共用同一转换(单个元素缺 id/图片时 toStockPhoto 会跳过)。
|
||||||
}
|
return stocksFromRaws(root.results as UnsplashRawPhoto[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一张搜索结果,带网格展示与装进画布所需的全部信息。 */
|
export const unsplashProvider: PhotoProvider = {
|
||||||
export interface UnsplashPhoto {
|
id: "unsplash",
|
||||||
id: string;
|
label: "Unsplash",
|
||||||
width: number;
|
// 申请 key 的页面,status 为 missing 时界面把它显示出来。
|
||||||
height: number;
|
keyUrl: "https://unsplash.com/oauth/applications",
|
||||||
description: string | null;
|
status: () => (readKey() ? "ready" : "missing"),
|
||||||
altDescription: string | null;
|
search: async (query, page) => stocksFromRaws(await fetchRawUnsplash(query, page, 15)),
|
||||||
authorName: string;
|
toAsset: toStockAsset,
|
||||||
thumbUrl: string;
|
/** 条款义务:用户选定一张图时打一次 download 统计。只有 Unsplash 有。 */
|
||||||
/** 装进画布用的尺寸(regular,约 1080px)。 */
|
notifyDownload(photo: StockPhoto): void {
|
||||||
regularUrl: string;
|
const key = readKey();
|
||||||
/** 原图页(带 utm_source,署名链接用)。 */
|
if (!key || !photo.downloadLocation) return;
|
||||||
pageUrl: string;
|
const sep = photo.downloadLocation.includes("?") ? "&" : "?";
|
||||||
/** API 要求的 download_location,选定该图时触发。 */
|
fetch(`${photo.downloadLocation}${sep}client_id=${encodeURIComponent(key)}`).catch(() => {
|
||||||
downloadLocation: string;
|
/* download 统计失败不影响用户操作 */
|
||||||
}
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** 当前是否配了 key。env 在 Vite 里必然有;Node/Bun/构建产物里可能没有,一律当缺失。 */
|
/** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */
|
||||||
export function unsplashStatus(): UnsplashStatus {
|
function toStockAsset(photo: StockPhoto): BackgroundAsset {
|
||||||
return readKey() ? "ready" : "missing";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 面板能直接用:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */
|
|
||||||
export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
|
|
||||||
const attribution: Attribution = {
|
const attribution: Attribution = {
|
||||||
text: `Photo by ${photo.authorName} on Unsplash`,
|
text: `Photo by ${photo.authorName} on Unsplash`,
|
||||||
url: photo.pageUrl,
|
url: photo.pageUrl,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
kind: "stock",
|
kind: "stock",
|
||||||
src: photo.regularUrl,
|
src: photo.fullUrl,
|
||||||
width: photo.width,
|
width: photo.width,
|
||||||
height: photo.height,
|
height: photo.height,
|
||||||
attribution,
|
attribution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchUnsplash(query: string): Promise<UnsplashPhoto[]> {
|
|
||||||
const key = readKey();
|
|
||||||
if (!key) throw new UnsplashError("未配置 Unsplash Access Key(VITE_UNSPLASH_KEY)");
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q) return [];
|
|
||||||
|
|
||||||
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&per_page=12&client_id=${encodeURIComponent(key)}`;
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(url, { headers: { "Accept-Version": "v1" } });
|
|
||||||
} catch {
|
|
||||||
throw new UnsplashError("连不上 Unsplash(网络或跨域限制)");
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401) throw new UnsplashError("Unsplash Access Key 无效,检查 .env.local");
|
|
||||||
if (res.status === 403 || res.status === 429)
|
|
||||||
throw new UnsplashError("Unsplash 请求被限流,等一会儿再试");
|
|
||||||
throw new UnsplashError(`Unsplash 返回 ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as UnsplashSearchResponse;
|
|
||||||
if (Array.isArray(data.errors) && data.errors.length) {
|
|
||||||
throw new UnsplashError(data.errors[0]);
|
|
||||||
}
|
|
||||||
return (data.results ?? []).map(toPhoto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户选定一张图时调用:触发 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 统计失败不影响用户操作 */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 内部 */
|
/* ---------------------------------------------------------------- 内部 */
|
||||||
|
|
||||||
function readKey(): string {
|
function readKey(): string {
|
||||||
@@ -103,13 +71,14 @@ function readKey(): string {
|
|||||||
return typeof raw === "string" ? raw.trim() : "";
|
return typeof raw === "string" ? raw.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnsplashSearchResponse = {
|
function appendParam(url: string, param: string): string {
|
||||||
results?: UnsplashRawPhoto[];
|
const sep = url.includes("?") ? "&" : "?";
|
||||||
errors?: string[];
|
return `${url}${sep}${param}`;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/** 原始 JSON 只取我们用得到的字段;没见过的字段一律不碰(不编造)。 */
|
||||||
type UnsplashRawPhoto = {
|
type UnsplashRawPhoto = {
|
||||||
id: string;
|
id?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
alt_description?: string | null;
|
alt_description?: string | null;
|
||||||
@@ -126,20 +95,82 @@ type UnsplashRawPhoto = {
|
|||||||
|
|
||||||
const UTM = "utm_source=cover-studio&utm_medium=referral";
|
const UTM = "utm_source=cover-studio&utm_medium=referral";
|
||||||
|
|
||||||
function toPhoto(raw: UnsplashRawPhoto): UnsplashPhoto {
|
function authorNameOf(raw: UnsplashRawPhoto): string {
|
||||||
const authorName = raw.user?.name?.trim() || "Unsplash 用户";
|
return raw.user?.name?.trim() || "Unsplash 用户";
|
||||||
const pageUrl = raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`;
|
}
|
||||||
const description = raw.description ?? null;
|
|
||||||
|
/**
|
||||||
|
* raw → StockPhoto(provider / parseUnsplashResponse 共用)。
|
||||||
|
* 空字段太多、连缩略图和原图页都没有的结果没法用,返回 null 让上层跳过。
|
||||||
|
*/
|
||||||
|
function toStockPhoto(raw: UnsplashRawPhoto): StockPhoto | null {
|
||||||
|
const id = raw.id;
|
||||||
|
const urls = raw.urls;
|
||||||
|
const pageUrl = raw.links?.html;
|
||||||
|
if (!id || !urls || !pageUrl) return null;
|
||||||
|
const rawUrl = urls.raw ?? "";
|
||||||
|
const regularUrl = urls.regular ?? "";
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id,
|
||||||
|
provider: "unsplash",
|
||||||
width: raw.width ?? 0,
|
width: raw.width ?? 0,
|
||||||
height: raw.height ?? 0,
|
height: raw.height ?? 0,
|
||||||
description,
|
thumbUrl: urls.small || urls.thumb || regularUrl,
|
||||||
altDescription: raw.alt_description ?? null,
|
// 装进画布用约 1600px 的档,不要原图(原图动辄 6000px,又慢又会撞滤镜纹理上限)。
|
||||||
authorName,
|
// imgix 的 raw 本身就是带参数的 URL,追加 &w=1600 等比缩到宽 1600。
|
||||||
thumbUrl: raw.urls?.small || raw.urls?.thumb || raw.urls?.regular || "",
|
fullUrl: rawUrl ? appendParam(rawUrl, "w=1600") : regularUrl,
|
||||||
regularUrl: raw.urls?.regular || raw.urls?.raw || "",
|
pageUrl: `${pageUrl}?${UTM}`,
|
||||||
pageUrl,
|
authorName: authorNameOf(raw),
|
||||||
downloadLocation: raw.links?.download_location ?? "",
|
// alt 优先,退到 description;Unsplash 两种都可能给空串,trim 后当没有。
|
||||||
|
alt: raw.alt_description?.trim() || raw.description?.trim() || null,
|
||||||
|
downloadLocation: raw.links?.download_location ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** raw 数组 → StockPhoto[](provider.search 用;parse 系列只认整棵官方响应)。 */
|
||||||
|
function stocksFromRaws(raws: readonly UnsplashRawPhoto[]): StockPhoto[] {
|
||||||
|
const out: StockPhoto[] = [];
|
||||||
|
for (const raw of raws) {
|
||||||
|
const s = toStockPhoto(raw);
|
||||||
|
if (s) out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(v: unknown): Record<string, unknown> | null {
|
||||||
|
return v && typeof v === "object" && !Array.isArray(v)
|
||||||
|
? (v as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉一次搜索原始结果;错误路径统一抛 PhotoProviderError。 */
|
||||||
|
async function fetchRawUnsplash(
|
||||||
|
query: string,
|
||||||
|
page: number,
|
||||||
|
perPage: number,
|
||||||
|
): Promise<UnsplashRawPhoto[]> {
|
||||||
|
const key = readKey();
|
||||||
|
if (!key) throw new PhotoProviderError("未配置 Unsplash Access Key(VITE_UNSPLASH_KEY)");
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
|
||||||
|
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&page=${page}&per_page=${perPage}&client_id=${encodeURIComponent(key)}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { "Accept-Version": "v1" } });
|
||||||
|
} catch {
|
||||||
|
throw new PhotoProviderError("连不上 Unsplash(网络或跨域限制)");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) throw new PhotoProviderError("Unsplash Access Key 无效,检查 .env.local");
|
||||||
|
if (res.status === 403 || res.status === 429)
|
||||||
|
throw new PhotoProviderError("Unsplash 请求被限流,等一会儿再试");
|
||||||
|
throw new PhotoProviderError(`Unsplash 返回 ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { results?: unknown[]; errors?: string[] };
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
throw new PhotoProviderError(data.errors[0]);
|
||||||
|
}
|
||||||
|
// 让 provider 与纯解析走同一条转换,解析逻辑只有一份。
|
||||||
|
return Array.isArray(data.results) ? (data.results as UnsplashRawPhoto[]) : [];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user