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 {}, }; }