chore: 本轮改造的共享契约与占位——ToolId/ShapeKind/LayerKind 扩充、Dropdown 壳、形状表、三个菜单与两个 composable 的桩

This commit is contained in:
2026-09-05 17:56:10 +08:00
parent 3a3798e37b
commit 19c8c604cb
17 changed files with 512 additions and 28 deletions
+9 -3
View File
@@ -35,10 +35,11 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import type { LayerInfo, LayerKind } from "@/types"; import type { LayerInfo, LayerKind } from "@/types";
import { useEditor } from "@/editor/useEditor"; import { useEditor } from "@/editor/useEditor";
import { useLayers } from "@/editor/useLayers"; import { useLayers } from "@/editor/useLayers";
import { shapeMeta } from "@/editor/shapes";
import { import {
IconImage, IconImage,
IconText, IconText,
IconRect, IconBackgroundTool,
IconEye, IconEye,
IconEyeOff, IconEyeOff,
IconLocked, IconLocked,
@@ -65,11 +66,16 @@ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v
/* ------------------------------------------------ 图标 */ /* ------------------------------------------------ 图标 */
/** 行首的类型图标。`other` 兜底用「多层」那个,表示不属于以上三类的对象。 */ /** 行首的类型图标。形状五种直接借形状表里的那份,和工具栏图标保持一致。 */
const KIND_ICON: Record<LayerKind, unknown> = { const KIND_ICON: Record<LayerKind, unknown> = {
background: IconBackgroundTool,
image: IconImage, image: IconImage,
text: IconText, text: IconText,
rect: IconRect, rect: shapeMeta("rect").icon,
line: shapeMeta("line").icon,
arrow: shapeMeta("arrow").icon,
ellipse: shapeMeta("ellipse").icon,
polygon: shapeMeta("polygon").icon,
other: IconTemplate, other: IconTemplate,
}; };
+4 -4
View File
@@ -48,15 +48,15 @@ const { canUndo, canRedo } = useHistory();
const TOOLS = [ const TOOLS = [
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect }, { cmd: "toolSelect", label: "选择工具", icon: IconSelect },
{ cmd: "toolText", label: "文字工具", icon: IconText }, { cmd: "toolText", label: "文字工具", icon: IconText },
{ cmd: "toolRect", label: "矩形工具", icon: IconRect }, { cmd: "toolShape", label: "形状工具", icon: IconRect },
{ cmd: "toolImage", label: "图片工具", icon: IconImage }, { cmd: "toolBackground", label: "背景工具", icon: IconImage },
] as const; ] as const;
const TOOL_OF: Record<string, ToolId> = { const TOOL_OF: Record<string, ToolId> = {
toolSelect: "select", toolSelect: "select",
toolText: "text", toolText: "text",
toolRect: "rect", toolShape: "shape",
toolImage: "image", toolBackground: "background",
}; };
function toolOf(cmd: string): ToolId { function toolOf(cmd: string): ToolId {
+2 -2
View File
@@ -93,8 +93,8 @@ function onPointerDown(e: PointerEvent) {
const scene = c.getScenePoint(e); const scene = c.getScenePoint(e);
void p; void p;
if (tool.value === "text") addText(); if (tool.value === "text") addText();
else if (tool.value === "rect") addRect({ x: scene.x, y: scene.y }); else if (tool.value === "shape") addRect({ x: scene.x, y: scene.y });
if (tool.value !== "image") { if (tool.value !== "background") {
tool.value = "select"; tool.value = "select";
commit(); commit();
} }
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】BackgroundMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(BackgroundMenu 待实现)</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】CanvasMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(CanvasMenu 待实现)</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】ShapeMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(ShapeMenu 待实现)</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
/**
* 顶栏三个下拉(画布编辑 / 形状 / 背景)共用的浮层壳。
*
* 它只管四件事:开合、点外面关掉、Esc 关掉、贴着触发器定位并在撞到窗口右边缘时
* 翻到左对齐。菜单**内容**由使用方通过 `panel` 插槽给,行的样式用 style.css 里的
* 全局 `.menu-*` 类(作用域样式够不到插槽内容,所以那几个类必须是全局的)。
*
* 触发器也由使用方给(`trigger` 插槽),插槽参数带 `open` 和 `toggle`,
* 这样按钮的高亮态和点击行为都由使用方决定,壳不猜。
*/
const props = withDefaults(
defineProps<{
/** 面板最小宽度 px。 */
minWidth?: number;
/** 面板相对触发器的水平对齐。撞到窗口边缘时会自动翻到另一边。 */
align?: "left" | "center";
}>(),
{ minWidth: 200, align: "left" },
);
const open = ref(false);
const rootEl = ref<HTMLElement | null>(null);
const panelEl = ref<HTMLElement | null>(null);
/** 面板相对 root(触发器)的左偏移 px。 */
const offsetX = ref(0);
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
defineExpose({ close });
/**
* 打开后量一次,撞到窗口右边缘就往左挪。
* 用 window 而不是父容器做边界:顶栏是 `overflow: hidden` 的,但面板挂在
* 顶栏里靠 `position: absolute` 浮出去,真正会裁掉它的是浏览器窗口。
*/
watch(open, async (val) => {
if (!val) return;
offsetX.value = 0;
await nextTick();
const root = rootEl.value;
const panel = panelEl.value;
if (!root || !panel) return;
const r = root.getBoundingClientRect();
const pw = panel.offsetWidth;
let x = props.align === "center" ? (r.width - pw) / 2 : 0;
const MARGIN = 8;
const leftAbs = r.left + x;
if (leftAbs + pw > window.innerWidth - MARGIN) x -= leftAbs + pw - (window.innerWidth - MARGIN);
if (r.left + x < MARGIN) x = MARGIN - r.left;
offsetX.value = x;
});
function onDocPointerDown(e: PointerEvent) {
if (!open.value) return;
if (rootEl.value && !rootEl.value.contains(e.target as Node)) close();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && open.value) {
e.preventDefault();
e.stopPropagation();
close();
}
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
// 捕获阶段:面板里有输入框时,Esc 要先关面板,不让全局快捷键抢走。
window.addEventListener("keydown", onKeyDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("keydown", onKeyDown, true);
});
</script>
<template>
<div ref="rootEl" class="dd">
<slot name="trigger" :open="open" :toggle="toggle" />
<div
v-if="open"
ref="panelEl"
class="dd-panel"
:style="{ left: offsetX + 'px', minWidth: minWidth + 'px' }"
@contextmenu.prevent
>
<slot name="panel" :close="close" />
</div>
</div>
</template>
<style scoped>
.dd {
position: relative;
display: flex;
align-items: center;
}
.dd-panel {
position: absolute;
top: calc(100% + 6px);
z-index: 40;
padding: 5px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg3);
box-shadow: 0 10px 34px rgb(0 0 0 / 0.55);
/* 顶栏很矮,面板可能比它长得多,超出就自己滚。 */
max-height: calc(100vh - var(--topbar-h) - 24px);
overflow-y: auto;
}
</style>
+2 -2
View File
@@ -178,8 +178,8 @@ export const COMMANDS: Command[] = [
/* ---------------- 工具 ---------------- */ /* ---------------- 工具 ---------------- */
{ id: "toolSelect", label: "选择工具", keys: ["v"], enabled: () => true, run: () => (ed.tool.value = "select") }, { id: "toolSelect", label: "选择工具", keys: ["v"], enabled: () => true, run: () => (ed.tool.value = "select") },
{ id: "toolText", label: "文字工具", keys: ["t"], enabled: () => true, run: () => (ed.tool.value = "text") }, { id: "toolText", label: "文字工具", keys: ["t"], enabled: () => true, run: () => (ed.tool.value = "text") },
{ id: "toolRect", label: "矩形工具", keys: ["r"], enabled: () => true, run: () => (ed.tool.value = "rect") }, { id: "toolShape", label: "形状工具", keys: ["r"], enabled: () => true, run: () => (ed.tool.value = "shape") },
{ id: "toolImage", label: "图片工具", keys: ["shift+k"], enabled: () => true, run: () => (ed.tool.value = "image") }, { id: "toolBackground", label: "背景工具", keys: ["shift+k"], enabled: () => true, run: () => (ed.tool.value = "background") },
/* ---------------- 视图 ---------------- */ /* ---------------- 视图 ---------------- */
{ id: "zoomIn", label: "放大", keys: ["+", "="], enabled: () => true, run: () => zoomBy(1.2) }, { id: "zoomIn", label: "放大", keys: ["+", "="], enabled: () => true, run: () => zoomBy(1.2) },
+33
View File
@@ -0,0 +1,33 @@
import type { ShapeKind } from "@/types";
import {
IconShapeRect,
IconShapeLine,
IconShapeArrow,
IconShapeEllipse,
IconShapePolygon,
} from "@/ui/icons";
/**
* 形状工具能画的五种形状。
*
* 这张表同时喂三处:工具栏形状按钮(显示当前形状的图标)、形状下拉(列出五项)、
* 图层面板(行首图标)。**图标是用户 2026-09-05 点名指定的,不要自己换。**
*/
export interface ShapeMeta {
kind: ShapeKind;
label: string;
/** 图标组件。类型留 unknown,避免这张表依赖 unplugin-icons 的组件类型。 */
icon: unknown;
}
export const SHAPES: ShapeMeta[] = [
{ kind: "rect", label: "矩形", icon: IconShapeRect },
{ kind: "line", label: "直线", icon: IconShapeLine },
{ kind: "arrow", label: "箭头", icon: IconShapeArrow },
{ kind: "ellipse", label: "圆形", icon: IconShapeEllipse },
{ kind: "polygon", label: "多边形", icon: IconShapePolygon },
];
export function shapeMeta(kind: ShapeKind): ShapeMeta {
return SHAPES.find((s) => s.kind === kind) ?? SHAPES[0];
}
+36
View File
@@ -0,0 +1,36 @@
import type { FabricObject } from "fabric";
import type { BackgroundAsset } from "@/types";
/**
* 【T13 待实现】专用背景图层。
*
* **背景不是「再加一张图片图层」**,这是这条线的全部要点:全画布最多一个背景图层,
* 它永远在最底(`sendObjectToBack`),换背景是**替换**它而不是往上堆。
* 对象上用 `obj.isBackground = true` 标记(已登记进 customProperties,能存进模板)。
*
* 契约:
* - `current()` 返回当前背景对象,没有返回 null。找法是遍历 `canvas.getObjects()`
* 找 `isBackground` 为真的那个,**不要另存一份引用**——`loadFromJSON` 之后
* 画布上的是新对象,缓存的引用会指向一个已经不在画布上的旧对象。
* - `setBackground(asset)` 装一份素材当背景:有旧的就换掉,按 `fit` 铺,置底,
* `registerLayer()`,`bumpRevision()`,然后 `commit()` 记一步历史。
* - `setSolid(color)` 纯色背景,用一个盖满画板的 Rect。
* - `clear()` 删掉背景图层。
* - 背景默认**不可选中**(`selectable: false`),否则点画布空白处老是选中它;
* 但它要出现在图层面板里,可以在那里选中、锁定、隐藏、删除。
* - 画板尺寸变了要重新铺(listen `useEditor().size`)。
*/
export function useBackground() {
return {
current(): FabricObject | null {
return null;
},
async setBackground(_asset: BackgroundAsset): Promise<FabricObject | null> {
return null;
},
setSolid(_color: string): FabricObject | null {
return null;
},
clear(): void {},
};
}
+63 -7
View File
@@ -1,6 +1,17 @@
import { Canvas, FabricImage, FabricObject, Rect, Textbox, type TPointerEvent } from "fabric"; import {
ActiveSelection,
Canvas,
Ellipse,
FabricImage,
FabricObject,
Line,
Polygon,
Rect,
Textbox,
type TPointerEvent,
} from "fabric";
import { ref, shallowRef } from "vue"; import { ref, shallowRef } from "vue";
import type { CanvasSize, LayerKind, SelectionKind, ToolId } from "@/types"; import type { CanvasSize, LayerKind, SelectionKind, ShapeKind, ToolId } from "@/types";
import { DEFAULT_SIZE } from "./canvasSize"; import { DEFAULT_SIZE } from "./canvasSize";
/** /**
@@ -13,7 +24,7 @@ import { DEFAULT_SIZE } from "./canvasSize";
*/ */
/** 自定义属性要登记在这里,`canvas.toJSON()` 才会带上(fabric v7 的 customProperties 机制)。 */ /** 自定义属性要登记在这里,`canvas.toJSON()` 才会带上(fabric v7 的 customProperties 机制)。 */
FabricObject.customProperties = ["layerId", "layerName", "layerLocked"]; FabricObject.customProperties = ["layerId", "layerName", "layerLocked", "isBackground", "shapeKind"];
declare module "fabric" { declare module "fabric" {
interface FabricObject { interface FabricObject {
@@ -23,6 +34,17 @@ declare module "fabric" {
layerName?: string; layerName?: string;
/** 锁定:不可选中、不可移动。和 fabric 自带的 selectable/evented 联动。 */ /** 锁定:不可选中、不可移动。和 fabric 自带的 selectable/evented 联动。 */
layerLocked?: boolean; layerLocked?: boolean;
/**
* 这是**那一个**专用背景图层。全画布最多一个,永远在最底层,
* 换背景是替换它而不是再加一层。归 `useBackground` 管,别处不要自己置位。
*/
isBackground?: boolean;
/**
* 形状工具画出来的形状是哪一种。fabric 的类推不出「箭头」——它就是一个
* Polygon/Path,和多边形同类;这个字段把用户的意图记下来,图层图标和
* 右栏属性都读它。
*/
shapeKind?: ShapeKind;
} }
} }
@@ -32,6 +54,8 @@ const selectionKind = ref<SelectionKind>("none");
const size = ref<CanvasSize>({ width: DEFAULT_SIZE.width, height: DEFAULT_SIZE.height }); const size = ref<CanvasSize>({ width: DEFAULT_SIZE.width, height: DEFAULT_SIZE.height });
/** 当前工具。`select` 是默认态。 */ /** 当前工具。`select` 是默认态。 */
const tool = ref<ToolId>("select"); const tool = ref<ToolId>("select");
/** 形状工具当前要画哪一种形状。工具栏形状按钮右边的下拉改它。 */
const shapeKind = ref<ShapeKind>("rect");
/** /**
* 画布内容的版本号。对象增删改、层序变化都会 +1。 * 画布内容的版本号。对象增删改、层序变化都会 +1。
* 图层面板、属性检查器 watch 它来重新推导自己的视图—— * 图层面板、属性检查器 watch 它来重新推导自己的视图——
@@ -49,23 +73,54 @@ export function ensureLayerId(obj: FabricObject): string {
export function layerKindOf(obj: FabricObject | null | undefined): LayerKind { export function layerKindOf(obj: FabricObject | null | undefined): LayerKind {
if (!obj) return "other"; if (!obj) return "other";
// 背景要先判:它本身是一张 FabricImage 或一个 Rect,光看类型分不出来。
if (obj.isBackground) return "background";
if (obj.shapeKind) return obj.shapeKind;
if (obj instanceof FabricImage) return "image"; if (obj instanceof FabricImage) return "image";
if (obj instanceof Textbox) return "text"; if (obj instanceof Textbox) return "text";
if (obj instanceof Ellipse) return "ellipse";
if (obj instanceof Line) return "line";
if (obj instanceof Polygon) return "polygon";
if (obj instanceof Rect) return "rect"; if (obj instanceof Rect) return "rect";
return "other"; return "other";
} }
/** 这个种类算不算「形状」——形状共用同一组外观属性(填充、描边、圆角)。 */
export function isShapeKind(kind: LayerKind): boolean {
return kind === "rect" || kind === "line" || kind === "arrow" || kind === "ellipse" || kind === "polygon";
}
const KIND_NAME: Record<LayerKind, string> = {
background: "背景",
image: "图片",
text: "文字",
rect: "矩形",
line: "直线",
arrow: "箭头",
ellipse: "圆形",
polygon: "多边形",
other: "对象",
};
export function defaultLayerName(obj: FabricObject): string { export function defaultLayerName(obj: FabricObject): string {
const kind = layerKindOf(obj); const kind = layerKindOf(obj);
if (kind === "text") return (obj as Textbox).text?.slice(0, 20) || "文字"; if (kind === "text") return (obj as Textbox).text?.slice(0, 20) || "文字";
if (kind === "image") return "图片"; return KIND_NAME[kind];
if (kind === "rect") return "矩形";
return "对象";
} }
/**
* 选中态:右栏据此决定显示哪几组属性。
*
* 五种形状塌成一个 `shape`——它们共用同一组外观属性,右栏没必要为每种各写一份。
* 背景图层归到 `image`:它就是一张图,滤镜之类的都适用。
*/
function kindOf(obj: FabricObject | null | undefined): SelectionKind { function kindOf(obj: FabricObject | null | undefined): SelectionKind {
if (!obj) return "none";
if (obj instanceof ActiveSelection) return "multi";
const k = layerKindOf(obj); const k = layerKindOf(obj);
return k === "other" ? (obj ? "other" : "none") : k === "rect" ? "other" : k; if (k === "background" || k === "image") return "image";
if (k === "text") return "text";
return isShapeKind(k) ? "shape" : "other";
} }
function syncSelection() { function syncSelection() {
@@ -285,6 +340,7 @@ export function useEditor() {
selectionKind, selectionKind,
size, size,
tool, tool,
shapeKind,
revision, revision,
mount, mount,
unmount, unmount,
+24
View File
@@ -0,0 +1,24 @@
import type { Canvas } from "fabric";
/**
* 【T12 待实现】形状工具的拖拽创建。
*
* 现状(要被替换掉的):Workspace 里点一下画布就在点击处放一个固定大小的矩形。
* 目标:按住左键从起点拖到终点松开,拖出多大就是多大,五种形状都走这一条路径。
*
* 契约:
* - `attachShapeTool(canvas)` 在画布挂载后调一次,返回解绑函数,卸载时调。
* - 只在 `useEditor().tool === "shape"` 时接管指针事件;其余时间完全让开,
* 不要影响选择/平移。
* - 画什么看 `useEditor().shapeKind`。
* - 拖拽过程中要有实时预览(把对象加进画布边拖边改,松手才 `commit()` 记一步历史)。
* - **按住 Shift 约束成 1:1**(正方形 / 正圆 / 45° 倍数的直线和箭头)。
* 注意这只管**新建**;已有对象缩放时的 Shift 语义保持现状,不要动。
* - 松手后回到 `select` 工具并选中新对象(和 Figma 一致)。
* - 拖拽距离小于阈值(约 4px)当作误触:放一个默认尺寸的形状,不要留下一个 0×0 的东西。
* - 新对象要 `registerLayer()`,并把 `obj.shapeKind` 置成当前形状——
* 箭头和多边形在 fabric 里都是 Polygon,光看类型分不出来。
*/
export function attachShapeTool(_canvas: Canvas): () => void {
return () => {};
}
+21
View File
@@ -0,0 +1,21 @@
import type { PhotoProvider, PhotoProviderId } from "@/types";
/** 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话,不让路径崩掉。 */
export class PhotoProviderError extends Error {
constructor(message: string) {
super(message);
this.name = "PhotoProviderError";
}
}
/**
* 【T14 待实现】图库注册表。
*
* 顺序即界面上标签页的顺序。**Pexels 在前**:Unsplash demo key 每小时只有 50 次
* 请求,开发期点几下就没了;Pexels 是每小时 200 次、每月 2 万次。
*/
export const PHOTO_PROVIDERS: PhotoProvider[] = [];
export function providerById(id: PhotoProviderId): PhotoProvider | undefined {
return PHOTO_PROVIDERS.find((p) => p.id === id);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
url: photo.pageUrl, url: photo.pageUrl,
}; };
return { return {
kind: "unsplash", kind: "stock",
src: photo.regularUrl, src: photo.regularUrl,
width: photo.width, width: photo.width,
height: photo.height, height: photo.height,
+66
View File
@@ -91,3 +91,69 @@ label {
font-size: 16px; font-size: 16px;
display: block; display: block;
} }
/* ================================================================ 下拉菜单
* 顶栏三个下拉(画布编辑 / 形状 / 背景)的行样式。
*
* **必须是全局类,不能写进 Dropdown.vue 的 scoped 样式**——菜单内容是插槽,
* 在使用方的作用域里渲染,Dropdown 自己的 scoped 样式够不到它。
*/
/* 一行可点的菜单项:图标 + 名字 +(可选)右侧键位/角标 */
.menu-row {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
height: 30px;
padding: 0 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.menu-row:hover:not(:disabled) {
background: color-mix(in srgb, var(--fg) 10%, transparent);
}
.menu-row.on {
color: var(--accent);
}
.menu-row:disabled {
color: var(--fg-dim);
opacity: 0.5;
cursor: default;
}
/* 行内靠右的次要信息:键位提示、当前值、子菜单角标 */
.menu-row .menu-aside {
margin-left: auto;
color: var(--fg-dim);
font-size: 11px;
white-space: nowrap;
}
/* 一段的小标题 */
.menu-title {
padding: 8px 9px 4px;
color: var(--fg-dim);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
}
.menu-sep {
height: 1px;
margin: 5px 6px;
background: var(--line);
}
/* 放非菜单行内容(输入框、卡片网格)的区域 */
.menu-block {
padding: 4px 9px 8px;
}
+87 -8
View File
@@ -1,7 +1,7 @@
/** /**
* 全仓共享的类型契约。 * 全仓共享的类型契约。
* *
* **这个文件是四条并行开发线之间唯一的接口。** 每条线只实现自己目录下的文件, * **这个文件是并行开发各线之间唯一的接口。** 每条线只实现自己目录下的文件,
* 通过这里的类型互相衔接,因此: * 通过这里的类型互相衔接,因此:
* *
* - 并行开发期间**不要改动本文件**。需要新增字段先在任务书里提出,由主控统一改。 * - 并行开发期间**不要改动本文件**。需要新增字段先在任务书里提出,由主控统一改。
@@ -98,7 +98,7 @@ export interface FilterDef {
/* ---------------------------------------------------------------- 背景来源(T4) */ /* ---------------------------------------------------------------- 背景来源(T4) */
export type BackgroundSourceKind = "upload" | "geometry" | "unsplash"; export type BackgroundSourceKind = "upload" | "geometry" | "stock";
/** /**
* 素材署名。**不画进图里**——由界面输出成可复制的文本,用户自己贴进文章。 * 素材署名。**不画进图里**——由界面输出成可复制的文本,用户自己贴进文章。
@@ -121,6 +121,54 @@ export interface BackgroundAsset {
attribution?: Attribution; attribution?: Attribution;
} }
/* ---------------------------------------------------------------- 图库(Pexels / Unsplash) */
/** 支持的图库。dev 期优先用 Pexels——Unsplash demo key 每小时只有 50 次请求。 */
export type PhotoProviderId = "pexels" | "unsplash";
/**
* 一条图库搜索结果,两家 API 的响应都归一到这个形状。
*
* 两家的差异只有一处漏出来:`downloadLocation`。Unsplash 的 API 条款要求
* 用户**选定**某张图时打一次下载统计端点,Pexels 没有这个要求,所以是可选的。
*/
export interface StockPhoto {
id: string;
provider: PhotoProviderId;
width: number;
height: number;
/** 网格缩略图。 */
thumbUrl: string;
/** 装进画布用的尺寸。约 1600px 长边,别取原图——原图动辄 6000px。 */
fullUrl: string;
/** 原图页,署名链接用。Unsplash 要求带 utm_source。 */
pageUrl: string;
authorName: string;
alt: string | null;
/** 仅 Unsplash:选定该图时要触发的统计端点。 */
downloadLocation?: string;
}
/** 图库当前能不能用。没配 key 一律 "missing",界面显示一句提示,不报错不崩。 */
export type PhotoProviderStatus = "missing" | "ready";
/**
* 图库适配器。两家各实现一份,界面只认这个接口,
* 不在组件里写 `if (provider === "pexels")`。
*/
export interface PhotoProvider {
id: PhotoProviderId;
label: string;
/** 申请 key 的页面,界面在 status 为 missing 时把它显示出来。 */
keyUrl: string;
status(): PhotoProviderStatus;
/** `page` 从 1 起。key 缺失或网络失败时抛 `PhotoProviderError`。 */
search(query: string, page: number): Promise<StockPhoto[]>;
toAsset(photo: StockPhoto): BackgroundAsset;
/** 只有 Unsplash 有实现;调用方无条件调,不判断是哪家。 */
notifyDownload?(photo: StockPhoto): void;
}
/* ---------------------------------------------------------------- 导出与模板(T5) */ /* ---------------------------------------------------------------- 导出与模板(T5) */
export type ExportFormat = "webp" | "png"; export type ExportFormat = "webp" | "png";
@@ -157,8 +205,11 @@ export interface Template {
/** 右侧栏面板。选中对象的类型决定显示哪几个。 */ /** 右侧栏面板。选中对象的类型决定显示哪几个。 */
export type PanelId = "canvas" | "source" | "filter" | "text" | "export"; export type PanelId = "canvas" | "source" | "filter" | "text" | "export";
/** 画布当前选中了什么,用来决定右栏显示哪些面板。 */ /**
export type SelectionKind = "none" | "image" | "text" | "other"; * 画布当前选中了什么,用来决定右栏显示哪些面板。
* `shape` 覆盖矩形/直线/箭头/圆/多边形——它们共用同一组外观属性(填充、描边、圆角)。
*/
export type SelectionKind = "none" | "image" | "text" | "shape" | "multi" | "other";
/* ================================================================ /* ================================================================
* 以下是 Figma 式编辑器改造(2026-09-05)新增的契约。 * 以下是 Figma 式编辑器改造(2026-09-05)新增的契约。
@@ -167,13 +218,41 @@ export type SelectionKind = "none" | "image" | "text" | "other";
/* ---------------------------------------------------------------- 工具 */ /* ---------------------------------------------------------------- 工具 */
/** 顶部工具栏的当前工具。`select` 是默认态,其余是「下一次点画布要放什么」。 */ /**
export type ToolId = "select" | "text" | "rect" | "image"; * 顶部工具栏的当前工具。`select` 是默认态。
*
* `shape` 不再是「点一下放一个矩形」,而是**按住拖出来**的一族形状,
* 具体画什么看 `ShapeKind`(工具栏那个下拉里选)。
*
* 原来的 `image` 工具去掉了:它点了没反应,而且「插入一张图」这件事
* 拖进来就能做。它的位置换成 `background`——改的是那一个专用背景图层。
*/
export type ToolId = "select" | "text" | "shape" | "background";
/**
* 形状工具当前要画的形状。工具栏形状按钮右侧的下拉切换它,
* 切换后按钮图标跟着变(和 Figma / Penpot 一致)。
*/
export type ShapeKind = "rect" | "line" | "arrow" | "ellipse" | "polygon";
/* ---------------------------------------------------------------- 图层 */ /* ---------------------------------------------------------------- 图层 */
/** 图层的种类,决定列表里的图标和右栏显示哪些属性组。 */ /**
export type LayerKind = "image" | "text" | "rect" | "other"; * 图层的种类,决定列表里的图标和右栏显示哪些属性组。
*
* `background` 是**那一个专用背景图层**,不是普通图片图层:它永远在最底,
* 换背景是替换它而不是再加一层,见 `useBackground`。
*/
export type LayerKind =
| "background"
| "image"
| "text"
| "rect"
| "line"
| "arrow"
| "ellipse"
| "polygon"
| "other";
/** /**
* 图层列表里的一行。**这是 fabric 对象的只读投影**,不是另一份状态—— * 图层列表里的一行。**这是 fabric 对象的只读投影**,不是另一份状态——
+21 -1
View File
@@ -17,8 +17,20 @@
* 尺寸和颜色由 CSS 控制:插件给每个图标挂了 `.icon` 类,规则在 style.css。 * 尺寸和颜色由 CSS 控制:插件给每个图标挂了 `.icon` 类,规则在 style.css。
*/ */
/* ---- 工具 ---- */ /* ---- 顶栏工具(图标由用户 2026-09-05 指定,别自己换) ---- */
export { default as IconCanvasEdit } from "~icons/hugeicons/align-selection";
export { default as IconSelect } from "~icons/hugeicons/cursor-01"; export { default as IconSelect } from "~icons/hugeicons/cursor-01";
export { default as IconTextTool } from "~icons/hugeicons/text";
export { default as IconBackgroundTool } from "~icons/hugeicons/image-composition";
/* ---- 形状(形状工具下拉里的五种) ---- */
export { default as IconShapeRect } from "~icons/hugeicons/solid-line-02";
export { default as IconShapeLine } from "~icons/hugeicons/liner";
export { default as IconShapeArrow } from "~icons/hugeicons/arrow-up-right-01";
export { default as IconShapeEllipse } from "~icons/hugeicons/circle";
export { default as IconShapePolygon } from "~icons/hugeicons/triangle";
/* ---- 图层行/分组用的类型图标 ---- */
export { default as IconText } from "~icons/hugeicons/text-font"; export { default as IconText } from "~icons/hugeicons/text-font";
export { default as IconRect } from "~icons/hugeicons/square"; export { default as IconRect } from "~icons/hugeicons/square";
export { default as IconImage } from "~icons/hugeicons/image-01"; export { default as IconImage } from "~icons/hugeicons/image-01";
@@ -75,7 +87,15 @@ export { default as IconTextRight } from "~icons/hugeicons/text-align-right";
export { default as IconFilter } from "~icons/hugeicons/blur"; export { default as IconFilter } from "~icons/hugeicons/blur";
export { default as IconTemplate } from "~icons/hugeicons/layers-01"; export { default as IconTemplate } from "~icons/hugeicons/layers-01";
/* ---- 图库 ---- */
export { default as IconSearch } from "~icons/hugeicons/search-01";
export { default as IconUpload } from "~icons/hugeicons/image-upload";
export { default as IconGeometry } from "~icons/hugeicons/grid";
/* ---- 通用 ---- */ /* ---- 通用 ---- */
export { default as IconChevronRight } from "~icons/hugeicons/chevron-right"; export { default as IconChevronRight } from "~icons/hugeicons/chevron-right";
export { default as IconChevronDown } from "~icons/hugeicons/chevron-down"; export { default as IconChevronDown } from "~icons/hugeicons/chevron-down";
export { default as IconPlus } from "~icons/hugeicons/plus-sign"; export { default as IconPlus } from "~icons/hugeicons/plus-sign";
export { default as IconCheck } from "~icons/hugeicons/tick-01";
export { default as IconClose } from "~icons/hugeicons/cancel-01";
export { default as IconLoading } from "~icons/hugeicons/reload";