Vue3 + Konva 实现商品定制编辑器:图片文字旋转、背景合成与坐标换算
Vue3 Konva 实现商品定制编辑器图片文字旋转、背景合成与坐标换算本文介绍如何用 Konva 封装一个可编辑区编辑器支持在商品背景图上叠加图片/文字、拖拽缩放旋转并导出「仅图层透明图」与「背景合成图」。前言电商定制场景里常见需求是在商品背景图上划定一块可编辑区域用户上传 Logo、输入文字并自由拖拽、缩放、旋转提交时把元素坐标换算到统一逻辑坐标系并导出预览图。本文给出一套基于Konva的最小实现参考画布采用600×600坐标系后台框选可编辑区时使用同一尺寸。一、能力一览能力说明可编辑区在背景图上划定矩形支持绕左上角旋转图片层导入、拖拽、等比缩放、旋转Transformer文字层添加/更新、拖拽、旋转双图导出透明底图层图 与背景合成的预览图坐标换算Stage 像素 ↔ 逻辑坐标系含旋转角存储与回显二、Stage 分层设计Stage ├── backdropLayer 背景图cover 铺满 ├── frameLayer 可编辑区边框 ├── paintLayer │ └── maskRoot 裁剪 区域旋转 │ └── layerRoot │ ├── imgLayer 用户图片 │ └── txtLayer 用户文字 ├── markLayer 尺寸标注cm └── handleLayer 缩放 / 旋转手柄要点图片、文字的坐标写在maskRoot局部系(0 … zoneW, 0 … zoneH)内。可编辑区整体旋转时只旋转maskRoot元素的rotation()表示相对可编辑区的局部角。三、快速开始3.1 安装npminstallkonva3.2 页面结构dividkit-hoststylewidth:100%;aspect-ratio:1/1;max-width:600px;/divbuttonidbtn-render导出合成图/buttonimgidpreviewaltpreview/3.3 初始化编辑器import{KonvaCanvasKit}from./canvasDesignKitconsthostdocument.getElementById(kit-host)asHTMLDivElementconstkitnewKonvaCanvasKit({container:host,// 600×600 参考系上的可编辑区[x, y, width, height]zoneRect:[150,150,300,300],cmSizeMark:{w:20,h:20},// UI 尺寸标注cmzoneSpin:0,// 可编辑区旋转角顺时针绕左上角logicCanvasSize:{width:300,height:300},// 提交/回显用的逻辑像素尺寸})awaitkit.mountBackdrop(https://example.com/product-bg.jpg)awaitkit.putImageLayer(https://example.com/logo.png)kit.putTextLayer(Hello Konva,#333333,Arial)3.4 导出图片document.getElementById(btn-render)!.onclick(){constoutputkit.renderOutputs()if(!output)returnconsole.log(图层透明图,output.layerPngUrl)console.log(背景合成图,output.previewPngUrl)console.log(图层位置,output.layoutBox)// layoutBox: { x, y, endX, endY }已换算到 logicCanvasSize 坐标系;(document.getElementById(preview)asHTMLImageElement).srcoutput.previewPngUrl}四、图片与文字旋转4.1 交互方式KonvaCanvasKit内置Konva.Transformer实例字段handleBox右上角锚点等比缩放旋转锚点rotater自由旋转点击选中图片或文字后拖动手柄即可。node.rotation()为相对可编辑区的局部旋转角。4.2 旋转角如何存后端可编辑区本身也可能带旋转角zoneSpin。持久化时使用合成角// 提交持久化角 局部角 区域角savedSpinmergeAngleForSave(localSpin,zoneSpin)// 回显localSpinsplitAngleForRender(savedSpin,zoneSpin)例可编辑区转 30°用户在区内把图片再转 15° → 持久化角 45°。4.3 读取与回显布局// 提交前constlayoutkit.collectLayerLayout()// layout.image → { x, y, width, height, spin, scaleX, scaleY }// 从服务端数据回显kit.applyLayerLayout(image,savedLayout.image)kit.applyLayerLayout(text,savedLayout.text)五、与背景图合成调用renderOutputs()得到字段说明layerPngUrl仅设计图层透明背景8 倍像素导出previewPngUrl背景 图层合成预览2 倍像素导出layoutBox图层包围盒在逻辑坐标系下的{ x, y, endX, endY }导出流程计算图片、文字在maskRoot内的合并包围盒对paintLayer局部toCanvas得到透明底图层图隐藏边框、标注、Transformer 等辅助层对整个Stage调用toDataURL()得到合成图。六、坐标与尺码换算6.1 几套坐标的含义名称示例值含义参考画布600×600后台框选可编辑区时使用的坐标系可编辑区矩形zoneRect[150,150,300,300]参考系内的[x, y, w, h]Stage 尺寸容器实际像素如 360×360Konva Stage 的宽高logicCanvasSize{ width: 300, height: 300 }与后端约定的逻辑像素尺寸6.2 参考系 → Stage 像素背景图以cover方式铺满 Stage 后可编辑区从参考系映射到舞台constREF_SIZECONFIG_CANVAS_SIZE// 600coverScalemax(stageW/bgNaturalW,stageH/bgNaturalH)refToStagecoverScale*(stageW/REF_SIZE)stageXbgOffsetXrefX*refToStage stageWrefW*refToStage封装函数projectZoneToStage(zoneRect, stageW, stageH, bgNaturalW, bgNaturalH)算例参考画布 600×600可编辑区[150, 150, 300, 300]Stage 容器 360×360背景图 800×800coverScale 360 / 800 0.45refToStage 0.45 × (360 / 600) 0.27可编辑区在 Stage 上的宽度 300 × 0.27 81px6.3 Stage ↔ 逻辑坐标提交/回显// Stage 像素 → 逻辑坐标提交给后端logicXstagePxToLogic(stageX,stageZoneW,logicWidth)// 逻辑坐标 → Stage 像素回显stageXlogicToStagePx(logicX,stageZoneW,logicWidth)collectLayerLayout()与applyLayerLayout()已封装上述换算。6.4 cm 标注UI 上的20cm × 20cm来自cmSizeMark用于展示。像素与厘米的换算比例由后端配置如widthCm / logicWidth前端按接口下发的logicCanvasSize处理即可。七、核心 APIKonvaCanvasKit 实例方法方法说明constructor(options)挂载到 DOM 容器relayoutStage(w, h)响应容器尺寸变化mountBackdrop(url)加载背景图putImageLayer(url)导入可编辑图片putTextLayer(content, color?, font?)添加或更新文字paintZoneFrame(spin?)重绘可编辑区含旋转collectLayerLayout()读取逻辑坐标布局applyLayerLayout(kind, layout)按布局回显图层renderOutputs()导出双图与 layoutBoxanyLayerPresent()是否已有图片或文字dispose()销毁 Stage工具函数与常量名称说明CONFIG_CANVAS_SIZE参考画布边长600projectZoneToStage()参考系矩形 → Stage 像素stagePxToLogic()Stage 值 → 逻辑值logicToStagePx()逻辑值 → Stage 值mergeAngleForSave()/splitAngleForRender()旋转角提交 / 回显主要类型类型说明ZoneRectTuple可编辑区[x, y, w, h]CmSizeMarkcm 尺寸标注{ w, h }LayerLayout图层布局{ x, y, width, height, spin, scaleX, scaleY }RenderOutput导出结果{ layerPngUrl, previewPngUrl, layoutBox }CanvasKitOptions初始化配置八、Vue3 组件封装template div refhostRef classkit-host / /template script setup langts import { ref, onMounted, onBeforeUnmount } from vue import { KonvaCanvasKit } from ./canvasDesignKit const hostRef refHTMLDivElement() let kit: KonvaCanvasKit | null null onMounted(async () { if (!hostRef.value) return kit new KonvaCanvasKit({ container: hostRef.value, zoneRect: [150, 150, 300, 300], cmSizeMark: { w: 20, h: 20 }, logicCanvasSize: { width: 300, height: 300 }, }) await kit.mountBackdrop(/assets/product-bg.jpg) }) onBeforeUnmount(() kit?.dispose()) defineExpose({ putImageLayer: (url: string) kit?.putImageLayer(url), putTextLayer: (text: string) kit?.putTextLayer(text), renderOutputs: () kit?.renderOutputs(), }) /script style scoped .kit-host { width: 100%; aspect-ratio: 1 / 1; max-width: 600px; } /style九、常见问题Q图层旋转后超出可编辑区怎么办A本文侧重流程演示。完整版可在transform/dragmove回调里用旋转矩形与轴对齐包围盒做碰撞检测将中心点限制在可编辑区内。Q为什么图层图和合成图导出倍率不同A图层常需更高分辨率用于印刷或二次合成默认LAYER_EXPORT_SCALE 8预览合成图 2x 即可满足展示可按业务调整LAYER_EXPORT_SCALE与PREVIEW_EXPORT_SCALE。Q跨域图片无法导出怎么办A加载图片时设置img.crossOrigin anonymous并确保 CDN 响应头包含Access-Control-Allow-Origin。十、完整源码以下为canvasDesignKit.ts完整代码依赖konva可直接复制到项目中使用。源码文件docs/canvasDesignKit.ts/** * canvasDesignKit.ts — Konva 可编辑区编辑器 * * 依赖konvanpm i konva * 参考画布600×600与后台框选可编辑区坐标系一致 */ import Konva from konva // --------------------------------------------------------------------------- // 常量 // --------------------------------------------------------------------------- /** 后台配置可编辑区时使用的参考画布边长 */ export const CONFIG_CANVAS_SIZE 600 /** 图层导出倍率 / 预览合成倍率 */ const LAYER_EXPORT_SCALE 8 const PREVIEW_EXPORT_SCALE 2 const HANDLE_THEME_COLOR #6b7280 const HANDLE_SIZE 20 // --------------------------------------------------------------------------- // 类型 // --------------------------------------------------------------------------- /** 可编辑区在参考系下的矩形[x, y, width, height] */ export type ZoneRectTuple [x: number, y: number, width: number, height: number] export interface CmSizeMark { w: number h: number } export interface GfxTransform { x: number y: number scaleX: number scaleY: number rotation: number offsetX?: number offsetY?: number fontSize?: number } export interface LayerLayout { x: number y: number width: number height: number spin: number scaleX: number scaleY: number } export interface RenderOutput { /** 仅图层透明底已裁切 */ layerPngUrl: string /** 图层 背景合成图 */ previewPngUrl: string /** 图层在逻辑画布坐标系内的包围盒 */ layoutBox: { x: number; y: number; endX: number; endY: number } } export interface CanvasKitOptions { container: HTMLDivElement /** 参考系可编辑区默认居中 300×300 600 画布 */ zoneRect?: ZoneRectTuple /** 尺寸标注cm仅 UI 展示 */ cmSizeMark?: CmSizeMark /** 可编辑区顺时针旋转角绕左上角 */ zoneSpin?: number /** 与后端约定的逻辑画布宽高像素 */ logicCanvasSize?: { width: number; height: number } readonly?: boolean } // --------------------------------------------------------------------------- // 坐标换算 // --------------------------------------------------------------------------- /** * 将参考画布上的可编辑区映射到当前 Stage背景 cover 铺满后 */ export function projectZoneToStage( zoneRect: ZoneRectTuple, stageW: number, stageH: number, bgNaturalW: number, bgNaturalH: number, refCanvasSize CONFIG_CANVAS_SIZE, ): { x: number; y: number; w: number; h: number; coverScale: number; offsetX: number; offsetY: number } { const coverScale Math.max(stageW / bgNaturalW, stageH / bgNaturalH) const displayW bgNaturalW * coverScale const displayH bgNaturalH * coverScale const offsetX (stageW - displayW) / 2 const offsetY (stageH - displayH) / 2 const refToStage coverScale * (stageW / refCanvasSize) const [rx, ry, rw, rh] zoneRect return { x: offsetX rx * refToStage, y: offsetY ry * refToStage, w: rw * refToStage, h: rh * refToStage, coverScale: refToStage, offsetX, offsetY, } } /** Stage 可编辑区像素 → 逻辑坐标提交 */ export function stagePxToLogic( stageValue: number, stageZoneSize: number, logicSize: number, ): number { return Math.round((stageValue * logicSize / stageZoneSize) * 10000) / 10000 } /** 逻辑坐标 → Stage 可编辑区像素回显 */ export function logicToStagePx( logicValue: number, stageZoneSize: number, logicSize: number, ): number { return logicValue * stageZoneSize / logicSize } /** 持久化角 局部角 区域角 */ export function mergeAngleForSave(localSpin: number, zoneSpin: number): number { return ((localSpin zoneSpin) % 360 360) % 360 } /** 回显局部角 持久化角 - 区域角 */ export function splitAngleForRender(savedSpin: number, zoneSpin: number): number { let v savedSpin - zoneSpin while (v 180) v - 360 while (v -180) v 360 return v } // --------------------------------------------------------------------------- // KonvaCanvasKit // --------------------------------------------------------------------------- export class KonvaCanvasKit { private stage: Konva.Stage private backdropLayer new Konva.Layer() private frameLayer new Konva.Layer() private paintLayer new Konva.Layer() private markLayer new Konva.Layer() private handleLayer new Konva.Layer() private maskRoot new Konva.Group() private layerRoot new Konva.Group({ x: 0, y: 0 }) private handleBox: Konva.Transformer private backdropNode: Konva.Image | null null private imgLayer: Konva.Image | null null private txtLayer: Konva.Text | null null private activeNode: Konva.Node | null null private zoneRectConfig: ZoneRectTuple private cmMark: CmSizeMark private zoneSpin: number private logicCanvasSize: { width: number; height: number } private readonly: boolean private bgNaturalW 0 private bgNaturalH 0 constructor(options: CanvasKitOptions) { this.zoneRectConfig options.zoneRect ?? [150, 150, 300, 300] this.cmMark options.cmSizeMark ?? { w: 20, h: 20 } this.zoneSpin options.zoneSpin ?? 0 this.logicCanvasSize options.logicCanvasSize ?? { width: 300, height: 300 } this.readonly options.readonly ?? false const { clientWidth, clientHeight } options.container this.stage new Konva.Stage({ container: options.container, width: clientWidth || 300, height: clientHeight || 300, }) this.maskRoot.add(this.layerRoot) this.paintLayer.add(this.maskRoot) this.handleBox new Konva.Transformer({ rotateEnabled: !this.readonly, resizeEnabled: !this.readonly, keepRatio: true, borderStroke: HANDLE_THEME_COLOR, anchorSize: HANDLE_SIZE, enabledAnchors: [top-right, rotater], }) this.stage.add(this.backdropLayer) this.stage.add(this.frameLayer) this.stage.add(this.paintLayer) this.stage.add(this.markLayer) this.stage.add(this.handleLayer) this.handleLayer.add(this.handleBox) this.syncMaskTransform() this.bindStageTap() } relayoutStage(w: number, h: number): void { this.stage.width(w) this.stage.height(h) this.repaintBackdrop() this.paintZoneFrame() } /** 加载背景图cover 铺满 Stage */ mountBackdrop(url: string): Promisevoid { return new Promise((resolve, reject) { const img new Image() img.crossOrigin anonymous img.onload () { this.bgNaturalW img.naturalWidth this.bgNaturalH img.naturalHeight this.backdropNode?.destroy() this.backdropNode new Konva.Image({ image: img, listening: false }) this.backdropLayer.destroyChildren() this.backdropLayer.add(this.backdropNode) this.repaintBackdrop() this.paintZoneFrame() resolve() } img.onerror reject img.src url }) } /** 导入图片层居中 contain可拖拽/旋转 */ putImageLayer(url: string): Promisevoid { return new Promise((resolve, reject) { const img new Image() img.crossOrigin anonymous img.onload () { const zone this.resolveZoneOnStage() const scale Math.min(zone.w / img.width, zone.h / img.height, 1) const w img.width * scale const h img.height * scale this.imgLayer?.destroy() this.imgLayer new Konva.Image({ image: img, x: zone.w / 2, y: zone.h / 2, offsetX: w / 2, offsetY: h / 2, width: w, height: h, draggable: !this.readonly, }) this.wireNodeTap(this.imgLayer) this.layerRoot.add(this.imgLayer) if (!this.readonly) this.focusNode(this.imgLayer) this.paintLayer.batchDraw() resolve() } img.onerror reject img.src url }) } /** 添加或更新文字层 */ putTextLayer(content: string, color #000000, fontFamily Arial): void { if (!content.trim()) { this.txtLayer?.destroy() this.txtLayer null return } if (this.txtLayer) { this.txtLayer.text(content) this.txtLayer.fill(color) this.txtLayer.fontFamily(fontFamily) this.paintLayer.batchDraw() return } const zone this.resolveZoneOnStage() this.txtLayer new Konva.Text({ text: content, x: zone.w / 2, y: zone.h / 2, fontSize: 18, fontFamily, fill: color, align: center, offsetX: 0, offsetY: 0, draggable: !this.readonly, }) this.txtLayer.offsetX(this.txtLayer.width() / 2) this.txtLayer.offsetY(this.txtLayer.height() / 2) this.wireNodeTap(this.txtLayer) this.layerRoot.add(this.txtLayer) if (!this.readonly) this.focusNode(this.txtLayer) this.paintLayer.batchDraw() } /** 读取图层在逻辑坐标系下的布局含旋转角 */ collectLayerLayout(): { image?: LayerLayout; text?: LayerLayout } | null { const zone this.resolveZoneOnStage() const sx this.logicCanvasSize.width / zone.w const sy this.logicCanvasSize.height / zone.h const mapNode (node: Konva.Node): LayerLayout { const box node.getClientRect({ relativeTo: this.maskRoot }) return { x: stagePxToLogic(box.x, zone.w, this.logicCanvasSize.width), y: stagePxToLogic(box.y, zone.h, this.logicCanvasSize.height), width: box.width * sx, height: box.height * sy, spin: mergeAngleForSave(node.rotation(), this.zoneSpin), scaleX: node.scaleX(), scaleY: node.scaleY(), } } const payload: { image?: LayerLayout; text?: LayerLayout } {} if (this.imgLayer) payload.image mapNode(this.imgLayer) if (this.txtLayer) payload.text mapNode(this.txtLayer) return Object.keys(payload).length ? payload : null } /** 按逻辑布局回显图层 */ applyLayerLayout(kind: image | text, layout: LayerLayout): void { const node kind image ? this.imgLayer : this.txtLayer if (!node) return const zone this.resolveZoneOnStage() node.rotation(splitAngleForRender(layout.spin, this.zoneSpin)) node.scaleX(layout.scaleX) node.scaleY(layout.scaleY) const cx logicToStagePx(layout.x layout.width / 2, zone.w, this.logicCanvasSize.width) const cy logicToStagePx(layout.y layout.height / 2, zone.h, this.logicCanvasSize.height) node.position({ x: cx, y: cy }) this.paintLayer.batchDraw() } /** 导出图层透明图 背景合成预览 */ renderOutputs(): RenderOutput | null { if (!this.imgLayer !this.txtLayer) return null this.blurFocus() const bounds this.mergeLayerBounds() if (!bounds) return null const zone this.resolveZoneOnStage() const layoutBox { x: stagePxToLogic(bounds.x, zone.w, this.logicCanvasSize.width), y: stagePxToLogic(bounds.y, zone.h, this.logicCanvasSize.height), endX: stagePxToLogic(bounds.x bounds.width, zone.w, this.logicCanvasSize.width), endY: stagePxToLogic(bounds.y bounds.height, zone.h, this.logicCanvasSize.height), } const layerCanvas this.paintLayer.toCanvas({ pixelRatio: LAYER_EXPORT_SCALE, x: bounds.x this.maskRoot.x(), y: bounds.y this.maskRoot.y(), width: bounds.width, height: bounds.height, }) const layerPngUrl layerCanvas.toDataURL(image/png) const frameVisible this.frameLayer.visible() const markVisible this.markLayer.visible() this.frameLayer.visible(false) this.markLayer.visible(false) this.handleLayer.visible(false) const previewPngUrl this.stage.toDataURL({ pixelRatio: PREVIEW_EXPORT_SCALE, mimeType: image/png, }) this.frameLayer.visible(frameVisible) this.markLayer.visible(markVisible) this.handleLayer.visible(true) return { layerPngUrl, previewPngUrl, layoutBox } } anyLayerPresent(): boolean { return !!(this.imgLayer || this.txtLayer) } dispose(): void { this.stage.destroy() } paintZoneFrame(spin this.zoneSpin): void { this.zoneSpin spin const zone this.resolveZoneOnStage() this.frameLayer.destroyChildren() this.drawZoneOutline(zone) this.syncMaskTransform() this.frameLayer.batchDraw() } private resolveZoneOnStage(): { x: number; y: number; w: number; h: number } { if (!this.bgNaturalW || !this.bgNaturalH) { const [x, y, w, h] this.zoneRectConfig return { x, y, w, h } } const mapped projectZoneToStage( this.zoneRectConfig, this.stage.width(), this.stage.height(), this.bgNaturalW, this.bgNaturalH, ) return { x: mapped.x, y: mapped.y, w: mapped.w, h: mapped.h } } private repaintBackdrop(): void { if (!this.backdropNode || !this.bgNaturalW) return const stageW this.stage.width() const stageH this.stage.height() const coverScale Math.max(stageW / this.bgNaturalW, stageH / this.bgNaturalH) const displayW this.bgNaturalW * coverScale const displayH this.bgNaturalH * coverScale this.backdropNode.position({ x: (stageW - displayW) / 2, y: (stageH - displayH) / 2, }) this.backdropNode.width(displayW) this.backdropNode.height(displayH) this.backdropLayer.batchDraw() } private drawZoneOutline(zone: { x: number; y: number; w: number; h: number }): void { this.frameLayer.add( new Konva.Rect({ x: zone.x, y: zone.y, width: zone.w, height: zone.h, rotation: this.zoneSpin, stroke: HANDLE_THEME_COLOR, strokeWidth: 1, dash: [4, 4], listening: false, }), ) this.markLayer.destroyChildren() this.markLayer.add( new Konva.Text({ text: ${this.cmMark.w}cm × ${this.cmMark.h}cm, x: zone.x, y: zone.y - 18, fontSize: 10, fill: #fff, listening: false, }), ) } private syncMaskTransform(): void { const zone this.resolveZoneOnStage() this.maskRoot.position({ x: zone.x, y: zone.y }) this.maskRoot.rotation(this.zoneSpin) this.maskRoot.clipFunc((ctx) { ctx.rect(0, 0, zone.w, zone.h) }) } private wireNodeTap(node: Konva.Node): void { node.on(click tap, (e) { e.cancelBubble true if (!this.readonly) this.focusNode(node) }) } private focusNode(node: Konva.Node): void { this.activeNode node this.handleBox.nodes([node]) this.handleLayer.batchDraw() } private blurFocus(): void { this.activeNode null this.handleBox.nodes([]) this.handleLayer.batchDraw() } private bindStageTap(): void { this.stage.on(click tap, (e) { if (e.target this.stage) this.blurFocus() }) } private mergeLayerBounds(): { x: number; y: number; width: number; height: number } | null { const nodes [this.imgLayer, this.txtLayer].filter(Boolean) as Konva.Node[] if (!nodes.length) return null let minX Infinity, minY Infinity, maxX -Infinity, maxY -Infinity nodes.forEach((node) { const box node.getClientRect({ relativeTo: this.maskRoot }) minX Math.min(minX, box.x) minY Math.min(minY, box.y) maxX Math.max(maxX, box.x box.width) maxY Math.max(maxY, box.y box.height) }) return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } } }主要包含KonvaCanvasKit类编辑器主体projectZoneToStage参考系到 Stage 的映射stagePxToLogic/logicToStagePx坐标互转mergeAngleForSave/splitAngleForRender旋转角换算总结本文实现了一个 Konva 定制编辑器的最小闭环600×600 参考系配置可编辑区映射到实际 Stage图片、文字支持Transformer 旋转与缩放双图导出透明图层图 背景合成图LayerLayout可在 Stage 与逻辑坐标系之间双向换算。按本文代码跑通后可在此基础上扩展边界约束、旋转角吸附、多图层、撤销重做等能力。如果这篇文章对你有帮助欢迎点赞收藏。有问题欢迎在评论区交流。