大家好,我是大华!
在公众号运营和内容创作中,图片版权保护是一个非常重要的问题。今天我分享一个基于Vue3图片水印添加的源码和实现步骤,即使你是前端新手也能轻松掌握!
一、项目效果预览
我们最终将实现一个这样的工具:
- 支持上传本地图片
- 两种水印模式:单水印(9个位置可选)和平铺水印
- 完全自定义水印样式:文本、字体、大小、颜色、透明度、旋转角度
- 实时预览效果
- 一键下载带水印的图片
效果图
完整源码在文末可直接复制。
二、环境准备
这个项目只需要一个HTML文件,不需要复杂的开发环境:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue3 图片水印添加器</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<!-- 我们的代码将写在这里 -->
</body>
</html>
三、项目结构设计
让我们先规划一下页面的整体结构:
scss
容器 (Container)
├── 头部 (Header)
├── 主要内容区域 (App Content)
├── 控制面板 (Controls)
│ ├── 图片上传区域
│ ├── 水印模式选择
│ ├── 水印设置
│ ├── 位置控制(单水印模式)
│ ├── 平铺设置(平铺模式)
│ └── 操作按钮
└── 预览区域 (Preview)
├── 图片预览容器
└── 预览信息
四、核心代码详解
1. Vue3应用初始化
javascript
const { createApp, ref, watch, onMounted } = Vue;
createApp({
setup() {
// 这里写我们的逻辑代码
}
}).mount('#app');
简单解释:
createApp
:创建Vue应用ref
:用于创建响应式数据watch
:监听数据变化onMounted
:页面加载完成后执行
2. 数据定义
javascript
const imageSrc = ref(''); // 存储上传的图片
const watermarkCanvas = ref(null); // 画布元素引用
// 水印配置数据
const watermark = ref({
mode: 'single', // 模式:single(单水印) 或 tile(平铺)
text: '示例水印', // 水印文字
fontFamily: 'Microsoft YaHei', // 字体
fontSize: 24, // 字体大小
color: '#ffffff', // 颜色
opacity: 0.7, // 透明度
rotation: -15, // 旋转角度
position: 'bottom-right', // 位置(单水印模式)
tileSpacingX: 100, // 水平间距(平铺模式)
tileSpacingY: 100 // 垂直间距(平铺模式)
});
响应式数据说明: 当这些数据发生变化时,页面会自动更新,这就是Vue3的响应式特性。
3. 图片上传功能
javascript
// 触发文件选择
const triggerFileInput = () => {
document.getElementById('fileInput').click();
};
// 处理图片上传
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
imageSrc.value = e.target.result; // 将图片转为base64格式
};
reader.readAsDataURL(file);
}
};
FileReader小知识: FileReader是浏览器提供的API,可以读取文件内容。readAsDataURL
方法将文件读取为Data URL格式,可以直接用在img标签的src属性中。
4. 水印绘制核心逻辑
这是整个项目最核心的部分,我们分步骤来看:
准备工作
javascript
const drawWatermark = () => {
if (!imageSrc.value || !watermarkCanvas.value) return;
const canvas = watermarkCanvas.value;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 设置canvas尺寸与图片一致
canvas.width = img.width;
canvas.height = img.height;
// 清除之前的绘制内容
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 设置水印样式
ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
ctx.fillStyle = watermark.value.color;
ctx.globalAlpha = watermark.value.opacity;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 根据模式绘制水印
if (watermark.value.mode === 'single') {
drawSingleWatermark(ctx, canvas);
} else {
drawTiledWatermark(ctx, canvas);
}
};
img.src = imageSrc.value;
};
单水印模式绘制
javascript
const drawSingleWatermark = (ctx, canvas) => {
let x, y;
const padding = 20; // 距离边缘的间距
// 根据选择的位置计算坐标
switch(watermark.value.position) {
case 'top-left':
x = padding;
y = padding;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
break;
case 'top-center':
x = canvas.width / 2;
y = padding;
ctx.textBaseline = 'top';
break;
// ... 其他7个位置的类似计算
}
// 应用旋转效果
ctx.save(); // 保存当前画布状态
ctx.translate(x, y); // 移动坐标原点到水印位置
ctx.rotate(watermark.value.rotation * Math.PI / 180); // 旋转
ctx.fillText(watermark.value.text, 0, 0); // 绘制文字
ctx.restore(); // 恢复画布状态
};
Canvas绘图要点:
save()
和restore()
:保存和恢复画布状态,避免旋转影响后续绘制translate()
:移动坐标原点rotate()
:旋转画布,参数是弧度(角度×π/180)
平铺水印模式绘制
javascript
const drawTiledWatermark = (ctx, canvas) => {
// 计算文字占据的宽度和高度
const textWidth = ctx.measureText(watermark.value.text).width;
const textHeight = watermark.value.fontSize;
// 计算每个水印单元的大小(文字+间距)
const unitWidth = textWidth + watermark.value.tileSpacingX;
const unitHeight = textHeight + watermark.value.tileSpacingY;
// 计算需要绘制多少行和列(+1确保覆盖整个图片)
const cols = Math.ceil(canvas.width / unitWidth) + 1;
const rows = Math.ceil(canvas.height / unitHeight) + 1;
// 循环绘制每个水印
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const x = j * unitWidth;
const y = i * unitHeight;
// 交错排列,让平铺效果更自然
const offsetX = (i % 2) * (unitWidth / 2);
ctx.save();
ctx.translate(x + offsetX, y);
ctx.rotate(watermark.value.rotation * Math.PI / 180);
ctx.fillText(watermark.value.text, 0, 0);
ctx.restore();
}
}
};
5. 图片下载功能
javascript
const downloadImage = () => {
if (!imageSrc.value) return;
// 创建临时canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 设置canvas尺寸
canvas.width = img.width;
canvas.height = img.height;
// 先绘制原始图片
ctx.drawImage(img, 0, 0);
// 再绘制水印(逻辑与预览时相同)
// ... 水印绘制代码
// 创建下载链接
const link = document.createElement('a');
link.download = 'watermarked-image.png';
link.href = canvas.toDataURL('image/png');
link.click(); // 触发下载
};
img.src = imageSrc.value;
};
下载原理:
- 创建隐藏的canvas
- 先画原始图片,再画水印
- 将canvas转为Data URL
- 创建a标签触发下载
6. 响应式更新
javascript
// 监听水印设置变化,实时更新预览
watch(watermark, () => {
drawWatermark();
}, { deep: true });
// 监听图片变化
watch(imageSrc, () => {
if (imageSrc.value) {
setTimeout(drawWatermark, 100);
}
});
watch函数说明:
- 第一个参数:要监听的数据
- 第二个参数:数据变化时执行的回调函数
deep: true
:深度监听,对象内部属性变化也会触发
五、界面设计与CSS技巧
1. 整体布局
使用Flexbox实现响应式布局:
css
.app-content {
display: flex;
flex-wrap: wrap;
gap: 20px; /* 元素间距 */
}
.controls {
flex: 1;
min-width: 320px; /* 最小宽度,确保在小屏幕上也能正常显示 */
}
.preview {
flex: 2;
min-width: 500px;
}
2. 网格布局实现9宫格位置选择
css
.position-controls {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3列等宽 */
gap: 5px; /* 格子间距 */
}
3. 视觉反馈
使用CSS过渡效果增强用户体验:
css
button {
transition: all 0.3s; /* 所有属性变化都有0.3秒过渡 */
}
button:hover {
transform: translateY(-2px); /* 悬停时轻微上移 */
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); /* 添加阴影 */
}
六、使用指南
1. 基本使用步骤
- 上传图片:点击"选择图片"按钮选择本地图片
- 选择模式:根据需求选择"单水印"或"平铺水印"
- 设置水印 :
- 单水印模式:调整位置、文字、样式
- 平铺模式:调整间距、文字、样式
- 实时预览:右侧查看效果
- 下载图片:满意后点击下载
2. 水印设置建议
- 文字颜色:建议使用白色或浅灰色,通过透明度调节效果
- 字体大小:根据图片尺寸调整,一般20-40px比较合适
- 旋转角度:-15°到-45°的斜向水印更难被去除
- 透明度:0.5-0.8之间既能看清又不会太突兀
七、技术亮点总结
- 纯前端实现:所有操作在浏览器完成,图片不上传服务器
- 实时预览:所有修改立即显示效果
- Canvas绘图:使用HTML5 Canvas实现精准的水印绘制
- Vue3响应式:数据驱动视图,代码简洁易维护
八、扩展思路
这个基础版本还可以继续增强:
- 图片水印:支持上传Logo图片作为水印
- 批量处理:一次为多张图片添加水印
- 模板保存:保存常用的水印设置
- 更多样式:文字阴影、描边等特效
- 压缩优化:下载时自动压缩图片大小
完整代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue3 图片水印添加器</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 1400px;
width: 100%;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
header {
background: linear-gradient(90deg, #4b6cb7 0%, #182848 100%);
color: white;
padding: 20px;
text-align: center;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
opacity: 0.8;
}
.app-content {
display: flex;
flex-wrap: wrap;
padding: 20px;
gap: 20px;
}
.controls {
flex: 1;
min-width: 320px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.preview {
flex: 2;
min-width: 500px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.control-group {
margin-bottom: 20px;
}
h2 {
font-size: 18px;
margin-bottom: 15px;
color: #2c3e50;
border-bottom: 1px solid #eaeaea;
padding-bottom: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #34495e;
}
input, select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
input[type="color"] {
height: 40px;
padding: 3px;
}
input[type="range"] {
padding: 0;
}
.range-value {
display: inline-block;
width: 40px;
text-align: center;
margin-left: 10px;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
button {
flex: 1;
padding: 12px;
border: none;
border-radius: 5px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.upload-btn {
background: #3498db;
color: white;
}
.download-btn {
background: #2ecc71;
color: white;
}
.reset-btn {
background: #e74c3c;
color: white;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
button:disabled {
background: #bdc3c7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.preview-container {
width: 100%;
max-width: 700px;
border: 1px solid #eaeaea;
border-radius: 10px;
overflow: hidden;
position: relative;
background: #f8f9fa;
min-height: 400px;
display: flex;
justify-content: center;
align-items: center;
}
#previewImage {
max-width: 100%;
max-height: 500px;
display: block;
}
.watermark-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.upload-placeholder {
text-align: center;
color: #7f8c8d;
padding: 40px;
}
.upload-placeholder i {
font-size: 48px;
margin-bottom: 15px;
display: block;
color: #bdc3c7;
}
.position-controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
margin-top: 10px;
}
.position-btn {
padding: 10px;
background: #ecf0f1;
border: 1px solid #bdc3c7;
border-radius: 5px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.position-btn.active {
background: #3498db;
color: white;
border-color: #2980b9;
}
.position-btn:hover {
background: #d5dbdb;
}
.position-btn.active:hover {
background: #2980b9;
}
.mode-controls {
display: flex;
gap: 10px;
margin-top: 10px;
}
.mode-btn {
flex: 1;
padding: 10px;
background: #ecf0f1;
border: 1px solid #bdc3c7;
border-radius: 5px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.mode-btn.active {
background: #9b59b6;
color: white;
border-color: #8e44ad;
}
.mode-btn:hover {
background: #d5dbdb;
}
.mode-btn.active:hover {
background: #8e44ad;
}
.tile-controls {
margin-top: 15px;
padding: 15px;
background: #e8f4fc;
border-radius: 8px;
border-left: 4px solid #3498db;
}
@media (max-width: 768px) {
.app-content {
flex-direction: column;
}
.controls, .preview {
min-width: 100%;
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<header>
<h1>Vue3 图片水印添加器</h1>
<p class="subtitle">上传图片,添加自定义水印,支持单水印和平铺模式</p>
</header>
<div class="app-content">
<div class="controls">
<div class="control-group">
<h2>图片上传</h2>
<input type="file" id="fileInput" @change="handleImageUpload" accept="image/*" style="display: none;">
<button class="upload-btn" @click="triggerFileInput">选择图片</button>
</div>
<div class="control-group">
<h2>水印模式</h2>
<div class="mode-controls">
<div class="mode-btn"
:class="{active: watermark.mode === 'single'}"
@click="watermark.mode = 'single'">单水印</div>
<div class="mode-btn"
:class="{active: watermark.mode === 'tile'}"
@click="watermark.mode = 'tile'">平铺水印</div>
</div>
</div>
<div class="control-group">
<h2>水印设置</h2>
<label for="watermarkText">水印文本</label>
<input type="text" id="watermarkText" v-model="watermark.text" placeholder="输入水印文本">
<label for="fontFamily">字体</label>
<select id="fontFamily" v-model="watermark.fontFamily">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Helvetica">Helvetica</option>
<option value="Tahoma">Tahoma</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Georgia">Georgia</option>
<option value="Microsoft YaHei">微软雅黑</option>
<option value="SimHei">黑体</option>
<option value="SimSun">宋体</option>
</select>
<label for="fontSize">字体大小: <span class="range-value">{{ watermark.fontSize }}px</span></label>
<input type="range" id="fontSize" v-model.number="watermark.fontSize" min="10" max="60" step="1">
<label for="color">颜色</label>
<input type="color" id="color" v-model="watermark.color">
<label for="opacity">透明度: <span class="range-value">{{ watermark.opacity }}</span></label>
<input type="range" id="opacity" v-model.number="watermark.opacity" min="0.1" max="1" step="0.1">
<label for="rotation">旋转角度: <span class="range-value">{{ watermark.rotation }}°</span></label>
<input type="range" id="rotation" v-model.number="watermark.rotation" min="0" max="360" step="1">
</div>
<div class="control-group" v-if="watermark.mode === 'single'">
<h2>水印位置</h2>
<div class="position-controls">
<div class="position-btn"
:class="{active: watermark.position === 'top-left'}"
@click="watermark.position = 'top-left'">左上</div>
<div class="position-btn"
:class="{active: watermark.position === 'top-center'}"
@click="watermark.position = 'top-center'">中上</div>
<div class="position-btn"
:class="{active: watermark.position === 'top-right'}"
@click="watermark.position = 'top-right'">右上</div>
<div class="position-btn"
:class="{active: watermark.position === 'center-left'}"
@click="watermark.position = 'center-left'">左中</div>
<div class="position-btn"
:class="{active: watermark.position === 'center'}"
@click="watermark.position = 'center'">中心</div>
<div class="position-btn"
:class="{active: watermark.position === 'center-right'}"
@click="watermark.position = 'center-right'">右中</div>
<div class="position-btn"
:class="{active: watermark.position === 'bottom-left'}"
@click="watermark.position = 'bottom-left'">左下</div>
<div class="position-btn"
:class="{active: watermark.position === 'bottom-center'}"
@click="watermark.position = 'bottom-center'">中下</div>
<div class="position-btn"
:class="{active: watermark.position === 'bottom-right'}"
@click="watermark.position = 'bottom-right'">右下</div>
</div>
</div>
<div class="control-group" v-if="watermark.mode === 'tile'">
<h2>平铺设置</h2>
<div class="tile-controls">
<label for="tileSpacingX">水平间距: <span class="range-value">{{ watermark.tileSpacingX }}px</span></label>
<input type="range" id="tileSpacingX" v-model.number="watermark.tileSpacingX" min="20" max="200" step="5">
<label for="tileSpacingY">垂直间距: <span class="range-value">{{ watermark.tileSpacingY }}px</span></label>
<input type="range" id="tileSpacingY" v-model.number="watermark.tileSpacingY" min="20" max="200" step="5">
</div>
</div>
<div class="button-group">
<button class="download-btn" @click="downloadImage" :disabled="!imageSrc">下载图片</button>
<button class="reset-btn" @click="resetWatermark">重置设置</button>
</div>
</div>
<div class="preview">
<h2>预览效果</h2>
<div class="preview-container">
<div v-if="!imageSrc" class="upload-placeholder">
<i>📷</i>
<p>请上传图片以添加水印</p>
</div>
<img v-else :src="imageSrc" id="previewImage" alt="预览图片">
<canvas class="watermark-canvas" ref="watermarkCanvas"></canvas>
</div>
<div v-if="imageSrc" class="preview-info">
<p>当前模式: <strong>{{ watermark.mode === 'single' ? '单水印' : '平铺水印' }}</strong></p>
<p v-if="watermark.mode === 'single'">位置: <strong>{{ getPositionText(watermark.position) }}</strong></p>
<p v-else>平铺间距: <strong>{{ watermark.tileSpacingX }}px × {{ watermark.tileSpacingY }}px</strong></p>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, watch, onMounted } = Vue;
createApp({
setup() {
const imageSrc = ref('');
const watermarkCanvas = ref(null);
const watermark = ref({
mode: 'single', // 'single' 或 'tile'
text: '示例水印',
fontFamily: 'Microsoft YaHei',
fontSize: 24,
color: '#ffffff',
opacity: 0.7,
rotation: -15,
position: 'bottom-right',
tileSpacingX: 100,
tileSpacingY: 100
});
const getPositionText = (position) => {
const positionMap = {
'top-left': '左上',
'top-center': '中上',
'top-right': '右上',
'center-left': '左中',
'center': '中心',
'center-right': '右中',
'bottom-left': '左下',
'bottom-center': '中下',
'bottom-right': '右下'
};
return positionMap[position] || position;
};
const triggerFileInput = () => {
document.getElementById('fileInput').click();
};
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
imageSrc.value = e.target.result;
};
reader.readAsDataURL(file);
}
};
const drawWatermark = () => {
if (!imageSrc.value || !watermarkCanvas.value) return;
const canvas = watermarkCanvas.value;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 设置canvas尺寸与图片一致
canvas.width = img.width;
canvas.height = img.height;
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 设置水印样式
ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
ctx.fillStyle = watermark.value.color;
ctx.globalAlpha = watermark.value.opacity;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (watermark.value.mode === 'single') {
// 单水印模式
let x, y;
const padding = 20;
switch(watermark.value.position) {
case 'top-left':
x = padding;
y = padding;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
break;
case 'top-center':
x = canvas.width / 2;
y = padding;
ctx.textBaseline = 'top';
break;
case 'top-right':
x = canvas.width - padding;
y = padding;
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
break;
case 'center-left':
x = padding;
y = canvas.height / 2;
ctx.textAlign = 'left';
break;
case 'center':
x = canvas.width / 2;
y = canvas.height / 2;
break;
case 'center-right':
x = canvas.width - padding;
y = canvas.height / 2;
ctx.textAlign = 'right';
break;
case 'bottom-left':
x = padding;
y = canvas.height - padding;
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
break;
case 'bottom-center':
x = canvas.width / 2;
y = canvas.height - padding;
ctx.textBaseline = 'bottom';
break;
case 'bottom-right':
x = canvas.width - padding;
y = canvas.height - padding;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
break;
}
// 应用旋转
ctx.save();
ctx.translate(x, y);
ctx.rotate(watermark.value.rotation * Math.PI / 180);
ctx.fillText(watermark.value.text, 0, 0);
ctx.restore();
} else {
// 平铺水印模式
const textWidth = ctx.measureText(watermark.value.text).width;
const textHeight = watermark.value.fontSize;
// 计算每个水印单元的大小(包括间距)
const unitWidth = textWidth + watermark.value.tileSpacingX;
const unitHeight = textHeight + watermark.value.tileSpacingY;
// 计算需要绘制的水印数量
const cols = Math.ceil(canvas.width / unitWidth) + 1;
const rows = Math.ceil(canvas.height / unitHeight) + 1;
// 平铺绘制水印
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const x = j * unitWidth;
const y = i * unitHeight;
// 交错排列,使平铺更自然
const offsetX = (i % 2) * (unitWidth / 2);
ctx.save();
ctx.translate(x + offsetX, y);
ctx.rotate(watermark.value.rotation * Math.PI / 180);
ctx.fillText(watermark.value.text, 0, 0);
ctx.restore();
}
}
}
};
img.src = imageSrc.value;
};
const downloadImage = () => {
if (!imageSrc.value) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
// 绘制原始图片
ctx.drawImage(img, 0, 0);
// 设置水印样式
ctx.font = `${watermark.value.fontSize}px ${watermark.value.fontFamily}`;
ctx.fillStyle = watermark.value.color;
ctx.globalAlpha = watermark.value.opacity;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (watermark.value.mode === 'single') {
// 单水印模式
let x, y;
const padding = 20;
switch(watermark.value.position) {
case 'top-left':
x = padding;
y = padding;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
break;
case 'top-center':
x = canvas.width / 2;
y = padding;
ctx.textBaseline = 'top';
break;
case 'top-right':
x = canvas.width - padding;
y = padding;
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
break;
case 'center-left':
x = padding;
y = canvas.height / 2;
ctx.textAlign = 'left';
break;
case 'center':
x = canvas.width / 2;
y = canvas.height / 2;
break;
case 'center-right':
x = canvas.width - padding;
y = canvas.height / 2;
ctx.textAlign = 'right';
break;
case 'bottom-left':
x = padding;
y = canvas.height - padding;
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
break;
case 'bottom-center':
x = canvas.width / 2;
y = canvas.height - padding;
ctx.textBaseline = 'bottom';
break;
case 'bottom-right':
x = canvas.width - padding;
y = canvas.height - padding;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
break;
}
// 应用旋转
ctx.save();
ctx.translate(x, y);
ctx.rotate(watermark.value.rotation * Math.PI / 180);
ctx.fillText(watermark.value.text, 0, 0);
ctx.restore();
} else {
// 平铺水印模式
const textWidth = ctx.measureText(watermark.value.text).width;
const textHeight = watermark.value.fontSize;
// 计算每个水印单元的大小(包括间距)
const unitWidth = textWidth + watermark.value.tileSpacingX;
const unitHeight = textHeight + watermark.value.tileSpacingY;
// 计算需要绘制的水印数量
const cols = Math.ceil(canvas.width / unitWidth) + 1;
const rows = Math.ceil(canvas.height / unitHeight) + 1;
// 平铺绘制水印
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const x = j * unitWidth;
const y = i * unitHeight;
// 交错排列,使平铺更自然
const offsetX = (i % 2) * (unitWidth / 2);
ctx.save();
ctx.translate(x + offsetX, y);
ctx.rotate(watermark.value.rotation * Math.PI / 180);
ctx.fillText(watermark.value.text, 0, 0);
ctx.restore();
}
}
}
// 创建下载链接
const link = document.createElement('a');
link.download = 'watermarked-image.png';
link.href = canvas.toDataURL('image/png');
link.click();
};
img.src = imageSrc.value;
};
const resetWatermark = () => {
watermark.value = {
mode: 'single',
text: '示例水印',
fontFamily: 'Microsoft YaHei',
fontSize: 24,
color: '#ffffff',
opacity: 0.7,
rotation: -15,
position: 'bottom-right',
tileSpacingX: 100,
tileSpacingY: 100
};
};
// 监听水印设置变化,实时更新预览
watch(watermark, () => {
drawWatermark();
}, { deep: true });
// 监听图片变化,更新水印
watch(imageSrc, () => {
if (imageSrc.value) {
// 等待DOM更新后绘制水印
setTimeout(drawWatermark, 100);
}
});
onMounted(() => {
// 初始化水印
drawWatermark();
});
return {
imageSrc,
watermarkCanvas,
watermark,
getPositionText,
triggerFileInput,
handleImageUpload,
downloadImage,
resetWatermark
};
}
}).mount('#app');
</script>
</body>
</html>
完整代码可以直接复制到HTML文件中运行,无需任何额外配置。
希望这篇教程能帮助你理解Vue3和Canvas的配合使用,并为你的图片版权保护提供有力工具。
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《SpringBoot 中的 7 种耗时统计方式,你用过几种?》