环境要求nuxtjs^2.15.8+nuxt-property-decorator^2.9.1+fabric.js "^5.3.0",无需在plugins下创建文件中引入
··· components/fabricCnavas.vue
js
<template>
<div class="canvas-wrapper">
<div class="canvas-container" ref="container">
<canvas ref="canvasEl"></canvas>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "nuxt-property-decorator";
declare const fabric: any;
@Component
export default class FabricCanvas extends Vue {
@Prop({ default: 800 }) width!: number;
@Prop({ default: 600 }) height!: number;
private fabric: any = null;
private canvas: any = null;
mounted() {
this.fabric = require("fabric").fabric || require("fabric");
// 1. 初始化画布
this.canvas = new this.fabric.Canvas(this.$refs.canvasEl, {
width: this.width,
height: this.height,
backgroundColor: "#ffffff",
preserveObjectStacking: true,
strokeUniform: true, // 【关键修复】让边框宽度保持固定,不随图形缩放而改变
});
// 强制开启"统一缩放变换",这会让图形在拖拽大小时,
// 实际上是改变了 width/height,而不是应用 scale 缩放,从而避免边框变形
this.canvas.uniScaleTransform = true;
// 2. 在拖拽缩放时,强制恢复边框宽度
this.canvas.on("object:scaling", (e: any) => {
const target = e.target;
// 如果有记录的原始宽度,就强制设回去
if (target && target.originalStrokeWidth) {
target.set("strokeWidth", target.originalStrokeWidth);
}
});
// 2. 绘制网格
this.drawGrid();
// 3. 监听选中变化
this.canvas.on("selection:created", () => this.emitSelection());
this.canvas.on("selection:updated", () => this.emitSelection());
this.canvas.on("selection:cleared", () => this.$emit("selection:cleared"));
// 4. 监听窗口大小变化
window.addEventListener("resize", this.handleResize);
this.$nextTick(() => {
this.handleResize();
});
this.$emit("ready", this.canvas);
}
beforeDestroy() {
window.removeEventListener("resize", this.handleResize);
if (this.canvas) {
this.canvas.dispose();
}
}
// --- 核心方法:处理全屏自适应 ---
private handleResize() {
if (!this.canvas) return;
const container = this.$refs.container as HTMLElement;
if (container) {
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
this.canvas.setDimensions({ width: newWidth, height: newHeight });
this.drawGrid(); // 重绘网格以适应新大小
this.canvas.renderAll();
}
}
// --- 绘图方法 (已加入随机位置逻辑) ---
addRect() {
const randomLeft = Math.random() * (this.canvas.width - 150) + 20;
const randomTop = Math.random() * (this.canvas.height - 150) + 20;
const colors = ["#42b983"];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
const rect = new this.fabric.Rect({
left: randomLeft,
top: randomTop,
fill: randomColor,
width: 100,
height: 100,
stroke: "#000000",
strokeWidth: 2,
strokeUniform: true, // 【关键修复】强制边框不随缩放变化
});
this.canvas.add(rect);
this.canvas.setActiveObject(rect);
}
addCircle() {
const randomLeft = Math.random() * (this.canvas.width - 150) + 20;
const randomTop = Math.random() * (this.canvas.height - 150) + 20;
const colors = ["#ff5722", "#42b983", "#1890ff", "#faad14"];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
const circle = new this.fabric.Circle({
left: randomLeft,
top: randomTop,
fill: randomColor,
radius: 50,
stroke: "#000000",
strokeWidth: 2,
strokeUniform: true, // 【关键修复】强制边框不随缩放变化
});
this.canvas.add(circle);
this.canvas.setActiveObject(circle);
}
addText() {
const randomLeft = Math.random() * (this.canvas.width - 200) + 20;
const randomTop = Math.random() * (this.canvas.height - 100) + 20;
const text = new this.fabric.IText("双击编辑", {
left: randomLeft,
top: randomTop,
fill: "#333333",
fontSize: 24,
fontFamily: "Arial",
stroke: "#000000",
strokeWidth: 0,
});
this.canvas.add(text);
this.canvas.setActiveObject(text);
}
deleteActive() {
const activeObjects = this.canvas.getActiveObjects();
if (activeObjects.length) {
this.canvas.discardActiveObject();
activeObjects.forEach((obj: any) => this.canvas.remove(obj));
}
}
downloadImage() {
const dataURL = this.canvas.toDataURL({
format: "png",
quality: 1,
multiplier: 2,
});
const link = document.createElement("a");
link.download = `design-${Date.now()}.png`;
link.href = dataURL;
link.click();
}
// --- 属性修改方法 ---
changeColor(color: string | null) {
const active = this.canvas.getActiveObject();
if (active) {
active.set("fill", color);
this.canvas.requestRenderAll();
}
}
changeOpacity(val: number) {
const active = this.canvas.getActiveObject();
if (active) {
active.set("opacity", val);
this.canvas.requestRenderAll();
}
}
changeStrokeColor(color: string | null) {
const active = this.canvas.getActiveObject();
if (active) {
active.set("stroke", color);
this.canvas.requestRenderAll();
}
}
changeStrokeWidth(width: number) {
const active = this.canvas.getActiveObject();
if (active) {
active.set("strokeWidth", width);
this.canvas.requestRenderAll();
}
}
changeStrokeDashArray(val: string) {
const active = this.canvas.getActiveObject();
if (active) {
let dashArray = null;
if (val === "dashed") dashArray = [10, 5];
if (val === "dotted") dashArray = [2, 2];
active.set("strokeDashArray", dashArray);
this.canvas.requestRenderAll();
}
}
bringForward() {
const active = this.canvas.getActiveObject();
if (active) this.canvas.bringForward(active);
}
sendBackwards() {
const active = this.canvas.getActiveObject();
if (active) this.canvas.sendBackwards(active);
}
// --- 内部逻辑 ---
private emitSelection() {
const active = this.canvas.getActiveObject();
if (active) {
this.$emit("selection:updated", {
fill: active.fill,
opacity: active.opacity,
type: active.type,
stroke: active.stroke,
strokeWidth: active.strokeWidth,
strokeDashArray: active.strokeDashArray,
});
}
}
private drawGrid() {
const existingGrid = this.canvas
.getObjects()
.filter((obj: any) => obj.isGrid);
existingGrid.forEach((obj: any) => this.canvas.remove(obj));
const gridSize = 20;
const width = this.canvas.width;
const height = this.canvas.height;
for (let i = 0; i <= width; i += gridSize) {
const line = new this.fabric.Line([i, 0, i, height], {
stroke: "#e0e0e0",
strokeWidth: 1,
selectable: false,
evented: false,
isGrid: true,
});
this.canvas.add(line);
}
for (let j = 0; j <= height; j += gridSize) {
const line = new this.fabric.Line([0, j, width, j], {
stroke: "#e0e0e0",
strokeWidth: 1,
selectable: false,
evented: false,
isGrid: true,
});
this.canvas.add(line);
}
this.canvas.sendToBack(this.canvas.getObjects()[0]);
}
}
</script>
<style scoped>
.canvas-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
background: #333;
overflow: hidden;
}
.canvas-container {
width: 100%;
height: 100%;
}
</style>
js
<template>
<div class="editor-page">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="group">
<button @click="canvasRef.addRect()">矩形</button>
<button @click="canvasRef.addCircle()">圆形</button>
<button @click="canvasRef.addText()">文字</button>
</div>
<div class="group">
<button @click="canvasRef.deleteActive()">删除</button>
<button @click="canvasRef.downloadImage()">导出图片</button>
</div>
</div>
<div class="main-body">
<!-- 左侧属性面板 -->
<div class="sidebar" v-if="selectedObj">
<h3>属性设置</h3>
<!-- 1. 填充设置 -->
<div class="prop-section">
<h4>填充</h4>
<div class="prop-header">
<label>启用填充</label>
<label class="switch-label">
<input type="checkbox" :checked="hasFill" @change="toggleFill" />
开启
</label>
</div>
<div v-if="hasFill" class="color-picker-wrapper">
<input
type="color"
:value="selectedObj.fill"
@change="updateColor($event.target.value)"
/>
</div>
<div v-else class="no-fill-tip">当前为透明/无填充</div>
</div>
<!-- 2. 边框设置 -->
<div class="prop-section">
<h4>边框</h4>
<div class="prop-header">
<label>启用边框</label>
<label class="switch-label">
<input
type="checkbox"
:checked="hasStroke"
@change="toggleStroke"
/>
开启
</label>
</div>
<div v-if="hasStroke" class="stroke-controls">
<div class="prop-item">
<label>颜色:</label>
<input
type="color"
:value="selectedObj.stroke"
@change="updateStrokeColor($event.target.value)"
/>
</div>
<div class="prop-item">
<label>粗细: {{ selectedObj.strokeWidth }}px</label>
<input
type="range"
min="0"
max="20"
step="1"
:value="selectedObj.strokeWidth"
@change="updateStrokeWidth($event.target.value)"
/>
</div>
<div class="prop-item">
<label>样式:</label>
<select
:value="strokeStyleType"
@change="updateStrokeStyle($event.target.value)"
>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
<option value="dotted">点线</option>
</select>
</div>
</div>
</div>
<!-- 3. 图层控制 -->
<div class="prop-section">
<h4>图层</h4>
<div class="btn-row">
<button @click="canvasRef.bringForward()">上移一层</button>
<button @click="canvasRef.sendBackwards()">下移一层</button>
</div>
</div>
</div>
<div class="sidebar empty" v-else>
<p>未选中任何对象</p>
</div>
<!-- 画布区域 -->
<div class="canvas-area">
<client-only>
<FabricCanvas
ref="canvasRef"
@selection:updated="onSelectionUpdated"
@selection:cleared="onSelectionCleared"
/>
</client-only>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "nuxt-property-decorator";
import FabricCanvas from "~/components/FabricCanvas.vue";
interface SelectionInfo {
fill: string | null;
opacity: number;
type: string;
stroke: string | null;
strokeWidth: number;
strokeDashArray: any;
}
@Component({
components: { FabricCanvas },
})
export default class EditorPage extends Vue {
selectedObj: SelectionInfo | null = null;
get canvasRef() {
return this.$refs.canvasRef as FabricCanvas;
}
get hasFill() {
return this.selectedObj !== null && this.selectedObj.fill !== null;
}
get hasStroke() {
return (
this.selectedObj !== null &&
this.selectedObj.strokeWidth > 0 &&
this.selectedObj.stroke !== null
);
}
get strokeStyleType() {
if (!this.selectedObj) return "solid";
const arr = this.selectedObj.strokeDashArray;
if (!arr) return "solid";
if (arr[0] === 10) return "dashed";
if (arr[0] === 2) return "dotted";
return "solid";
}
onSelectionUpdated(info: SelectionInfo) {
this.selectedObj = { ...info };
}
onSelectionCleared() {
this.selectedObj = null;
}
toggleFill(e: any) {
const isChecked = e.target.checked;
if (isChecked) {
this.selectedObj!.fill = "#42b983";
} else {
this.selectedObj!.fill = null;
}
this.canvasRef.changeColor(this.selectedObj!.fill);
}
updateColor(color: string) {
if (this.selectedObj) {
this.selectedObj.fill = color;
this.canvasRef.changeColor(color);
}
}
toggleStroke(e: any) {
const isChecked = e.target.checked;
if (isChecked) {
this.selectedObj!.stroke = "#000000";
this.selectedObj!.strokeWidth = 2;
} else {
this.selectedObj!.strokeWidth = 0;
}
this.updateStrokeWidth(this.selectedObj!.strokeWidth);
}
updateStrokeColor(color: string) {
if (this.selectedObj) {
this.selectedObj.stroke = color;
this.canvasRef.changeStrokeColor(color);
}
}
updateStrokeWidth(val: string) {
if (this.selectedObj) {
const width = parseInt(val);
this.selectedObj.strokeWidth = width;
this.canvasRef.changeStrokeWidth(width);
}
}
updateStrokeStyle(val: string) {
this.canvasRef.changeStrokeDashArray(val);
}
}
</script>
<style scoped>
.editor-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
.toolbar {
height: 60px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 20px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
z-index: 10;
}
.group {
display: flex;
gap: 10px;
padding-right: 20px;
border-right: 1px solid #eee;
}
.group:last-child {
border-right: none;
}
button {
padding: 8px 16px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
color: rgba(0, 0, 0, 0.85);
}
button:hover {
color: #1890ff;
border-color: #1890ff;
}
.main-body {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.sidebar {
width: 260px;
background: #fff;
border-right: 1px solid #e8e8e8;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.sidebar.empty {
justify-content: center;
align-items: center;
color: #999;
text-align: center;
}
.prop-section h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
font-weight: 600;
}
.prop-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.switch-label {
font-size: 12px !important;
color: #1890ff !important;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-weight: normal !important;
}
.switch-label input {
margin: 0;
}
.color-picker-wrapper input {
width: 100%;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 2px;
background: #fff;
cursor: pointer;
}
.no-fill-tip {
padding: 12px;
background: #fafafa;
border: 1px dashed #d9d9d9;
color: #999;
text-align: center;
font-size: 12px;
border-radius: 4px;
}
.stroke-controls {
background: #fafafa;
padding: 10px;
border-radius: 4px;
border: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 10px;
}
.prop-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.prop-item label {
font-size: 13px;
color: #555;
}
input[type="range"] {
width: 100%;
cursor: pointer;
}
select {
width: 100%;
height: 30px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 5px;
outline: none;
}
select:focus {
border-color: #1890ff;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-row button {
flex: 1;
font-size: 12px;
padding: 6px;
}
.canvas-area {
flex: 1;
height: 100%;
background: #333;
position: relative;
overflow: hidden;
}
</style>