diff --git a/src/components/topbar/BackgroundMenu.vue b/src/components/topbar/BackgroundMenu.vue
index 46feb3e..4793151 100644
--- a/src/components/topbar/BackgroundMenu.vue
+++ b/src/components/topbar/BackgroundMenu.vue
@@ -1,7 +1,557 @@
-
+
+
+
diff --git a/src/editor/useBackground.ts b/src/editor/useBackground.ts
index 8d8654a..824f068 100644
--- a/src/editor/useBackground.ts
+++ b/src/editor/useBackground.ts
@@ -1,36 +1,157 @@
-import type { FabricObject } from "fabric";
+import { FabricImage, Rect, type FabricObject } from "fabric";
+import { watch } from "vue";
import type { BackgroundAsset } from "@/types";
+import { useEditor } from "./useEditor";
+import { commit } from "./useHistory";
+import { capImageSize } from "./imageLimits";
/**
- * 【T13 待实现】专用背景图层。
+ * 【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`)。
+ * 关键实现约束(都是踩出来的,别再踩):
+ * - `current()` 每次现找,不要缓存引用——`loadFromJSON` 之后画布上是新对象,
+ * 缓存的引用会指向一个已经不在画布上的旧对象。
+ * - `selectable: false` 要放在**最后**设:`canvas.add()` 和 `sendObjectToBack()`
+ * 都会触发 `object:added`,而 useEditor 那个监听器会再跑一次 `registerLayer()`
+ * (`applyLocked(obj,false)` 会把 selectable 翻回 true),所以必须先 add、先
+ * 置底,最后再关 selectable。
+ * - 铺满用 `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() {
return {
- current(): FabricObject | null {
- return null;
+ current,
+
+ /** 装一份素材当背景:有旧的先换掉,按 cover 铺满,置底,记一步历史。 */
+ async setBackground(asset: BackgroundAsset): Promise {
+ 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;
+ fitToCanvas(img);
+ registerLayer(img);
+ c.add(img);
+ c.sendObjectToBack(img);
+ // 最后再关 selectable:add/sendObjectToBack 的 object:added 会把 selectable
+ // 翻回 true(见文件头注释)。背景在画布上不可点选,但图层面板里仍能选中。
+ img.selectable = false;
+ c.requestRenderAll();
+ bumpRevision();
+ commit();
+ return img;
},
- async setBackground(_asset: BackgroundAsset): Promise {
- 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;
+ registerLayer(rect);
+ c.add(rect);
+ c.sendObjectToBack(rect);
+ rect.selectable = false;
+ 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 {},
};
}