chore: 本轮改造的共享契约与占位——ToolId/ShapeKind/LayerKind 扩充、Dropdown 壳、形状表、三个菜单与两个 composable 的桩
This commit is contained in:
@@ -35,10 +35,11 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
|
||||
import type { LayerInfo, LayerKind } from "@/types";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
import { useLayers } from "@/editor/useLayers";
|
||||
import { shapeMeta } from "@/editor/shapes";
|
||||
import {
|
||||
IconImage,
|
||||
IconText,
|
||||
IconRect,
|
||||
IconBackgroundTool,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
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> = {
|
||||
background: IconBackgroundTool,
|
||||
image: IconImage,
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,15 +48,15 @@ const { canUndo, canRedo } = useHistory();
|
||||
const TOOLS = [
|
||||
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect },
|
||||
{ cmd: "toolText", label: "文字工具", icon: IconText },
|
||||
{ cmd: "toolRect", label: "矩形工具", icon: IconRect },
|
||||
{ cmd: "toolImage", label: "图片工具", icon: IconImage },
|
||||
{ cmd: "toolShape", label: "形状工具", icon: IconRect },
|
||||
{ cmd: "toolBackground", label: "背景工具", icon: IconImage },
|
||||
] as const;
|
||||
|
||||
const TOOL_OF: Record<string, ToolId> = {
|
||||
toolSelect: "select",
|
||||
toolText: "text",
|
||||
toolRect: "rect",
|
||||
toolImage: "image",
|
||||
toolShape: "shape",
|
||||
toolBackground: "background",
|
||||
};
|
||||
|
||||
function toolOf(cmd: string): ToolId {
|
||||
|
||||
@@ -93,8 +93,8 @@ function onPointerDown(e: PointerEvent) {
|
||||
const scene = c.getScenePoint(e);
|
||||
void p;
|
||||
if (tool.value === "text") addText();
|
||||
else if (tool.value === "rect") addRect({ x: scene.x, y: scene.y });
|
||||
if (tool.value !== "image") {
|
||||
else if (tool.value === "shape") addRect({ x: scene.x, y: scene.y });
|
||||
if (tool.value !== "background") {
|
||||
tool.value = "select";
|
||||
commit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/** 【占位】BackgroundMenu 的内容由对应开发线实现。见任务书。 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-block">(BackgroundMenu 待实现)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/** 【占位】CanvasMenu 的内容由对应开发线实现。见任务书。 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-block">(CanvasMenu 待实现)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/** 【占位】ShapeMenu 的内容由对应开发线实现。见任务书。 */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-block">(ShapeMenu 待实现)</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -178,8 +178,8 @@ export const COMMANDS: Command[] = [
|
||||
/* ---------------- 工具 ---------------- */
|
||||
{ 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: "toolRect", label: "矩形工具", keys: ["r"], enabled: () => true, run: () => (ed.tool.value = "rect") },
|
||||
{ id: "toolImage", label: "图片工具", keys: ["shift+k"], enabled: () => true, run: () => (ed.tool.value = "image") },
|
||||
{ id: "toolShape", label: "形状工具", keys: ["r"], enabled: () => true, run: () => (ed.tool.value = "shape") },
|
||||
{ id: "toolBackground", label: "背景工具", keys: ["shift+k"], enabled: () => true, run: () => (ed.tool.value = "background") },
|
||||
|
||||
/* ---------------- 视图 ---------------- */
|
||||
{ id: "zoomIn", label: "放大", keys: ["+", "="], enabled: () => true, run: () => zoomBy(1.2) },
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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 type { CanvasSize, LayerKind, SelectionKind, ToolId } from "@/types";
|
||||
import type { CanvasSize, LayerKind, SelectionKind, ShapeKind, ToolId } from "@/types";
|
||||
import { DEFAULT_SIZE } from "./canvasSize";
|
||||
|
||||
/**
|
||||
@@ -13,7 +24,7 @@ import { DEFAULT_SIZE } from "./canvasSize";
|
||||
*/
|
||||
|
||||
/** 自定义属性要登记在这里,`canvas.toJSON()` 才会带上(fabric v7 的 customProperties 机制)。 */
|
||||
FabricObject.customProperties = ["layerId", "layerName", "layerLocked"];
|
||||
FabricObject.customProperties = ["layerId", "layerName", "layerLocked", "isBackground", "shapeKind"];
|
||||
|
||||
declare module "fabric" {
|
||||
interface FabricObject {
|
||||
@@ -23,6 +34,17 @@ declare module "fabric" {
|
||||
layerName?: string;
|
||||
/** 锁定:不可选中、不可移动。和 fabric 自带的 selectable/evented 联动。 */
|
||||
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 });
|
||||
/** 当前工具。`select` 是默认态。 */
|
||||
const tool = ref<ToolId>("select");
|
||||
/** 形状工具当前要画哪一种形状。工具栏形状按钮右边的下拉改它。 */
|
||||
const shapeKind = ref<ShapeKind>("rect");
|
||||
/**
|
||||
* 画布内容的版本号。对象增删改、层序变化都会 +1。
|
||||
* 图层面板、属性检查器 watch 它来重新推导自己的视图——
|
||||
@@ -49,23 +73,54 @@ export function ensureLayerId(obj: FabricObject): string {
|
||||
|
||||
export function layerKindOf(obj: FabricObject | null | undefined): LayerKind {
|
||||
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 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";
|
||||
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 {
|
||||
const kind = layerKindOf(obj);
|
||||
if (kind === "text") return (obj as Textbox).text?.slice(0, 20) || "文字";
|
||||
if (kind === "image") return "图片";
|
||||
if (kind === "rect") return "矩形";
|
||||
return "对象";
|
||||
return KIND_NAME[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中态:右栏据此决定显示哪几组属性。
|
||||
*
|
||||
* 五种形状塌成一个 `shape`——它们共用同一组外观属性,右栏没必要为每种各写一份。
|
||||
* 背景图层归到 `image`:它就是一张图,滤镜之类的都适用。
|
||||
*/
|
||||
function kindOf(obj: FabricObject | null | undefined): SelectionKind {
|
||||
if (!obj) return "none";
|
||||
if (obj instanceof ActiveSelection) return "multi";
|
||||
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() {
|
||||
@@ -285,6 +340,7 @@ export function useEditor() {
|
||||
selectionKind,
|
||||
size,
|
||||
tool,
|
||||
shapeKind,
|
||||
revision,
|
||||
mount,
|
||||
unmount,
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
|
||||
url: photo.pageUrl,
|
||||
};
|
||||
return {
|
||||
kind: "unsplash",
|
||||
kind: "stock",
|
||||
src: photo.regularUrl,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
|
||||
@@ -91,3 +91,69 @@ label {
|
||||
font-size: 16px;
|
||||
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
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 全仓共享的类型契约。
|
||||
*
|
||||
* **这个文件是四条并行开发线之间唯一的接口。** 每条线只实现自己目录下的文件,
|
||||
* **这个文件是并行开发各线之间唯一的接口。** 每条线只实现自己目录下的文件,
|
||||
* 通过这里的类型互相衔接,因此:
|
||||
*
|
||||
* - 并行开发期间**不要改动本文件**。需要新增字段先在任务书里提出,由主控统一改。
|
||||
@@ -98,7 +98,7 @@ export interface FilterDef {
|
||||
|
||||
/* ---------------------------------------------------------------- 背景来源(T4) */
|
||||
|
||||
export type BackgroundSourceKind = "upload" | "geometry" | "unsplash";
|
||||
export type BackgroundSourceKind = "upload" | "geometry" | "stock";
|
||||
|
||||
/**
|
||||
* 素材署名。**不画进图里**——由界面输出成可复制的文本,用户自己贴进文章。
|
||||
@@ -121,6 +121,54 @@ export interface BackgroundAsset {
|
||||
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) */
|
||||
|
||||
export type ExportFormat = "webp" | "png";
|
||||
@@ -157,8 +205,11 @@ export interface Template {
|
||||
/** 右侧栏面板。选中对象的类型决定显示哪几个。 */
|
||||
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)新增的契约。
|
||||
@@ -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 对象的只读投影**,不是另一份状态——
|
||||
|
||||
+21
-1
@@ -17,8 +17,20 @@
|
||||
* 尺寸和颜色由 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 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 IconRect } from "~icons/hugeicons/square";
|
||||
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 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 IconChevronDown } from "~icons/hugeicons/chevron-down";
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user