1、下載konva插件
官網地址
npm install vue-konva konva --save
2、在主文件中引入,如main.js
import VueKonva from 'vue-konva';
app.use(VueKonva);
3、組件內使用,我現在的布局是左側是畫布,右側是相關設置(顏色、標題等)
<div class="share_poster_wrap"><div class="poster_image_wrap"><div class="poster_img_left"><div class="loading-mask" v-if="loading"></div><div class="custom_loading" v-if="loading"><div class="loading_spinner"></div><p>AI生成背景中,請稍等...</p></div><p class="poster_img_tips"><Icon icon="svg-icon:tips" :size="14" />可以拖拽調整海報上任意元素的位置</p><div class="poster_img"><v-stageref="stage":config="stageSize"@dragmove="handleDragMove":key="stageKey"style="border-radius: 12px; overflow: hidden"><v-layer ref="layer"><v-rect:config="{width: stageSize.width,height: stageSize.height,fill: '#fff',listening: false,stroke: 'transparent',cornerRadius: 12,}"/><v-image :config="bgImageConfig" name="image" /><v-text :config="formTitleConfig" name="formTitleText" /><v-group :config="qrGroupConfig" name="qrGroup"><v-rect :config="qrConfig" name="qr" /><v-rect :config="qrLogoWrapConfig" name="qrLogo" /><v-rect :config="qrLogoConfig" name="qrLogo" /></v-group><v-rect :config="logoConfig" name="logo" /><v-circle :config="avatarConfig" name="circle" /><v-text :config="inviteConfig" name="inviteText" /><v-text :config="nickNameConfig" name="nickNameText" /><v-text :config="tipsConfig" name="tipsText" /></v-layer></v-stage></div><el-button @click="downloadImage" class="download_btn">下載海報</el-button></div><div class="poster_img_right"></div></div>
已上是我的html部分,左側就是我的海報部分,是有頭像標題和二維碼以及背景,大家可以根據需求來變動。
const stageSize = {width: 340,height: 460,background: '#fff',
};// 背景圖片配置
const bgImageConfig: any = ref({width: stageSize.width,height: stageSize.height,image: null, // 先初始化為空draggable: true,opacity: 1, // 確保不透明cornerRadius: [0, 0, 0, 0],// cornerRadius: 12,
});// 二維碼組配置
const qrGroupConfig: any = ref({width: 136,height: 136,fill: '#fff',x: 100,y: 232,draggable: true,stroke: 'transparent',shadowEnabled: false,perfectDrawEnabled: false,
});// 二維碼
const qrConfig: any = ref({width: 136,height: 136,cornerRadius: 6,x: 0,y: 0,
});// 二維碼中間的logo外層
const qrLogoWrapConfig: any = ref({width: 30,height: 30,x: unref(qrConfig).x + (unref(qrConfig).width - 30) / 2,y: unref(qrConfig).y + (unref(qrConfig).height - 30) / 2,fill: '#fff',
});// 二維碼中間的logo
const qrLogoConfig: any = ref({width: 30,height: 30,x: unref(qrLogoWrapConfig).x + (unref(qrLogoWrapConfig).width - 30) / 2,y: unref(qrLogoWrapConfig).y + (unref(qrLogoWrapConfig).height - 30) / 2,
});// 全局logo
const logoConfig: any = ref({width: 50,height: 50,x: 14,y: 14,fill: 'transparent',draggable: true,
});// 頭像
const avatarConfig: any = ref({width: 50,height: 50,x: stageSize.width / 2,y: 80,radius: 25,draggable: true,stroke: '#fff',strokeWidth: 2,
});// 標題
const formTitleConfig: any = ref({text: '',fontSize: 16,x: stageSize.width / 2,y: 186,draggable: true,fontStyle: 'bold',dragged: false, // 是否被拖拽過
});// 邀請
const inviteConfig: any = ref({text: '',fontSize: 14,x: stageSize.width / 2,y: 144,draggable: true,dragged: false, // 是否被拖拽過
});// 昵稱
const nickNameConfig: any = ref({text: '',fontSize: 16,x: stageSize.width / 2,y: 116,draggable: true,dragged: false, // 是否被拖拽過
});// 邀請文案
const tipsConfig: any = ref({text: '',fontSize: 14,x: stageSize.width / 2,y: 384,draggable: true,dragged: false, // 是否被拖拽過
});const stage = ref(); // 獲取stage引用class DrawPosterImage {// 通用圖片加載方法private async loadImage(url: string): Promise<HTMLImageElement | null> {if (!url) return null;try {const img = new Image();img.crossOrigin = 'Anonymous';return await new Promise((resolve, reject) => {img.onload = () => resolve(img);img.onerror = () => reject(null);img.src = url;if (img.complete) resolve(img);});} catch {return null;}}// 通用配置更新方法private updateConfig<T extends object>(configRef: Ref<T>, updates: Partial<T>) {configRef.value = { ...configRef.value, ...updates };}// 通用圖片繪制方法private async drawImage(configRef: Ref<any>,url: string,options: {width?: number;height?: number;cornerRadius?: number;maintainAspectRatio?: boolean;} = {},) {if (!url) {this.updateConfig(configRef, {fill: 'transparent',fillPatternImage: undefined,});return;}const img = await this.loadImage(url);if (!img) {this.updateConfig(configRef, {fill: 'transparent',fillPatternImage: undefined,});return;}const updates: any = {fill: undefined,fillPatternImage: img,fillPatternRepeat: 'no-repeat',};if (options.maintainAspectRatio !== false) {const containerWidth = options.width || configRef.value.width;const containerHeight = options.height || configRef.value.height;const scale = Math.min(containerWidth / img.width, containerHeight / img.height);updates.fillPatternScale = { x: scale, y: scale };// 計算居中偏移const offsetX = (containerWidth - img.width * scale) / 2;const offsetY = (containerHeight - img.height * scale) / 2;updates.fillPatternOffset = {x: -offsetX / scale,y: -offsetY / scale,};}if (options.cornerRadius !== undefined) {updates.cornerRadius = options.cornerRadius;}this.updateConfig(configRef, updates);}public getDataUrl(): string | undefined {const stageNode = stage.value?.getStage();if (!stageNode) return;const dataURL = stageNode.toDataURL({pixelRatio: 1,mimeType: 'image/png',x: 0,y: 0,width: stageSize.width,height: stageSize.height,});posterImgUrl.value = dataURL;posterUrl.value = dataURL;return dataURL;}public async drawBackground(url: string, preserveColors = false): Promise<void> {const img = await this.loadImage(url);if (img) {const bgHeight = img.height > 460 ? 460 : img.height;this.updateConfig(bgImageConfig, {image: img,width: stageSize.width,height: bgHeight,});if (!preserveColors) {this.updateConfig(formTitleConfig, { fill: '#333333' });this.updateConfig(nickNameConfig, { fill: '#666666' });this.updateConfig(inviteConfig, { fill: '#666666' });this.updateConfig(tipsConfig, { fill: '#666666' });}}}public async drawCreaterAvatar(url = ''): Promise<void> {const img = await this.loadImage(url);if (img) {circleImg(img, stageSize.width / 2, 80);} else {this.updateConfig(avatarConfig, {fillPatternImage: undefined,stroke: 'transparent',strokeWidth: 2,});}}public drawCreaterNickName(nickName: string): void {this.updateConfig(nickNameConfig, {text: nickName,});}public drawInvitation(msg: string): void {this.updateConfig(inviteConfig, {text: msg,});}public async drawQRCode(url = '', isChange = false): Promise<void> {const img = await this.loadImage(url);if (img) {const scale = Math.min(qrConfig.value.width / img.width, qrConfig.value.height / img.height);this.updateConfig(qrConfig, {fillPatternImage: img,fillPatternScale: { x: scale, y: scale },fillPatternOffset: { x: 0, y: 0 },fillPatternRepeat: 'no-repeat',});if (!isChange) {this.updateConfig(tipsConfig, {text: '長按掃碼進行填寫',});}const logoUrl =unref(qrCodeLogo).length > 0 && unref(showCustomLogo)? unref(qrCodeLogo): unref(defaultInnerLogo);if (logoUrl) {const isType1 = unref(qrcodeType) === 1;const logoWrapSize = isType1 ? 30 : 60;const logoSize = isType1 ? 25 : 60;const cornerRadius = isType1 ? 5 : 50;await this.drawImage(qrLogoConfig, logoUrl, {width: logoSize,height: logoSize,cornerRadius,maintainAspectRatio: true,});this.updateConfig(qrLogoWrapConfig, {width: logoWrapSize,height: logoWrapSize,x: unref(qrConfig).x + (unref(qrConfig).width - logoWrapSize) / 2,y: unref(qrConfig).y + (unref(qrConfig).height - logoWrapSize) / 2,cornerRadius,});this.updateConfig(qrLogoConfig, {width: logoSize,height: logoSize,x: unref(qrConfig).x + (unref(qrConfig).width - logoSize) / 2,y: unref(qrConfig).y + (unref(qrConfig).height - logoSize) / 2,});}}}public async drawLogoImg(url = ''): Promise<void> {await this.drawImage(logoConfig, url, {maintainAspectRatio: true,});}public drawFormTitle(title = ''): void {const text = title.length > 30 ? `${title.substring(0, 30)}...` : title;this.updateConfig(formTitleConfig, {text,fontSize: 16,});}public cacheNodes() {const stageNode = stage.value?.getStage();if (!stageNode) return;// 緩存所有可拖動元素stageNode.find('Group').forEach((group) => group.cache());stageNode.find('Text').forEach((text) => text.cache());stageNode.find('Image').forEach((img) => img.cache());}
}// 文本默認居中
const updateTextPositions = () => {nextTick(() => {const stageNode = stage.value?.getStage();if (!stageNode) return;texts.forEach(({ config, name }) => {const textNode = stageNode.findOne(`.${name}`);if (textNode && !config.value.dragged) {// 直接使用預設的stageSize.width,避免依賴實時計算const textWidth = textNode.width();config.value.x = stageSize.width / 2 - textWidth / 2;}});stageNode.draw(); // 強制重繪});
};let drawController = new DrawPosterImage();const createPoster = async (backgroundImg: string, preserveColors = false) => {const bgImg = backgroundImg.length > 0 ? backgroundImg : unref(prevBgImg);const avatar = unref(templateConf)?.posterAvatar?.length? unref(templateConf)?.posterAvatar: defaultAvatar;await Promise.all([await drawController.drawBackground(bgImg, preserveColors),(await showCustomAvatar.value)? drawController.drawCreaterAvatar(avatar): drawController.drawCreaterAvatar(),drawController.drawCreaterNickName(unref(templateConf)?.posterNickname ?? unref(defaultNickName),),await drawController.drawQRCode(unref(qrcodeUrl)),await drawController.drawLogoImg(unref(showCustomLogo) ? unref(leftLogo) : ''),await drawController.drawFormTitle(unref(templateConf).name || '未命名表單'),await drawController.drawInvitation(unref(shareInputModel)),]).then(() => {drawController.getDataUrl();prevBgImg.value = bgImg;// updateData();});
};const bgImgList = shareBgConf.shareBgList; // 會有模板這里模板的背景list// 因為要記錄下當前的修改,所以這部分存在了pinia當中,刷新頁面后回歸原樣。
onMounted(async () => {const setInitialTextPositions = () => {// 標題const formTitle = unref(templateConf).name || '未命名表單';const formTitleWidth = calculateTextWidth(formTitle, 16, 'bold');formTitleConfig.value.x = (stageSize.width - formTitleWidth) / 2;// 昵稱const nickName = unref(nickNameInputModel) || unref(templateConf)?.posterNickname || '新用戶';const nickNameWidth = calculateTextWidth(nickName, 16);nickNameConfig.value.x = (stageSize.width - nickNameWidth) / 2;// 邀請語const inviteText = unref(shareInputModel) || '邀請您填寫';const inviteWidth = calculateTextWidth(inviteText, 14);inviteConfig.value.x = (stageSize.width - inviteWidth) / 2;// 邀請tipsconst tipsText = '長按掃碼進行填寫';const tipsWidth = calculateTextWidth(tipsText, 14);tipsConfig.value.x = (stageSize.width - tipsWidth) / 2;};setInitialTextPositions(); // 初始設置正確位置if (unref(shareData)?.shareCustomAvatar) {avatarImg.value = unref(shareData)?.shareCustomAvatar || defaultAvatar;} else {if ((unref(templateConf)?.posterAvatar || '').length > 0) {avatarImg.value = (await getSafeImg(unref(templateConf)!.posterAvatar!)) || '';} else {avatarImg.value = defaultAvatar;}}await nextTick();await createPoster(unref(customBg) || bgImgList[0], unref(customBg) ? false : true);updateTextPositions();}
});// 移動事件實時更新數據,因為我要保存下當前的位置等信息
const handleDragMove = (e) => {const node = e.target;const { width: stageWidth, height: stageHeight } = stageSize;// 直接忽略不需要處理的節點if (node.getName() === 'image') {handleBackgroundDrag(node);return;}// 通用邊界限制函數const constrainPosition = (value, min, max) => Math.max(min, Math.min(value, max));// 計算節點的邊界限制let newX = node.x();let newY = node.y();if (node.getName() === 'circle') {const radius = node.radius();newX = constrainPosition(newX, radius / 2, stageWidth - radius / 2);newY = constrainPosition(newY, radius / 2, stageHeight - radius / 2);} else {const width = node.width ? node.width() : 0;const height = node.height ? node.height() : 0;newX = constrainPosition(newX, 0, stageWidth - width);newY = constrainPosition(newY, 0, stageHeight - height);}// 更新節點位置node.x(newX);node.y(newY);const configMap = {inviteText: inviteConfig,nickNameText: nickNameConfig,formTitleText: formTitleConfig,tipsText: tipsConfig,qrGroup: qrGroupConfig,logo: logoConfig,circle: avatarConfig,};const config = configMap[node.getName()];if (config) {config.value.x = newX;config.value.y = newY;config.value.dragged = true;updateData();}
};const updateData = () => {const elementPositions = {formTitle: {x: formTitleConfig.value.x,y: formTitleConfig.value.y,fontSize: formTitleConfig.value.fontSize,fill: formTitleConfig.value.fill,fontStyle: formTitleConfig.value.fontStyle,},invite: {x: inviteConfig.value.x,y: inviteConfig.value.y,fontSize: inviteConfig.value.fontSize,fill: inviteConfig.value.fill,fontStyle: inviteConfig.value.fontStyle,},nickName: {x: nickNameConfig.value.x,y: nickNameConfig.value.y,fontSize: nickNameConfig.value.fontSize,fill: nickNameConfig.value.fill,fontStyle: nickNameConfig.value.fontStyle,},tips: {x: tipsConfig.value.x,y: tipsConfig.value.y,fontSize: tipsConfig.value.fontSize,fill: tipsConfig.value.fill,fontStyle: tipsConfig.value.fontStyle,},qrGroup: {x: qrGroupConfig.value.x,y: qrGroupConfig.value.y,},logo: {x: logoConfig.value.x,y: logoConfig.value.y,},avatar: {x: avatarConfig.value.x,y: avatarConfig.value.y,},};emit('update', {formKey: unref(formkey),shareTitle: unref(shareInputModel),shareNickName: unref(nickNameInputModel),shareCustomBackGround: unref(customBg).length > 0 ? unref(prevBgImg) : '',// shareCustomLogo: unref(logoImg),// shareCustomAvatar: unref(avatarImg),qrcodeType: unref(qrcodeType),showCustomLogo: unref(showCustomLogo),showCustomAvatar: unref(showCustomAvatar),elementPositions,});
};
以上是我的ts部分,封裝了個類里邊是相關繪制方法以及各自的配置。
至于右側的設置,就是當修改后畫布重新繪制即可。