first commit

This commit is contained in:
lingxiao865
2026-02-10 08:05:03 +08:00
commit c5af079d8c
1094 changed files with 97530 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
:: BASE_DOC ::
## API
### Watermark Props
name | type | default | description | required
-- | -- | -- | -- | --
custom-style | Object | - | CSS(Cascading Style Sheets) | N
alpha | Number | 1 | \- | N
content | String | - | \- | N
height | Number | - | \- | N
is-repeat | Boolean | true | \- | N
layout | String | rectangular | options: rectangular/hexagonal | N
line-space | Number | 16 | \- | N
movable | Boolean | false | \- | N
move-interval | Number | 3000 | \- | N
offset | Array | - | Typescript`Array<number>` | N
removable | Boolean | true | \- | N
rotate | Number | -22 | \- | N
watermark-content | Object / Array | - | Typescript`WatermarkText\|WatermarkImage\|Array<WatermarkText\|WatermarkImage>` | N
width | Number | - | \- | N
x | Number | - | \- | N
y | Number | - | \- | N
z-index | Number | - | \- | N
### Watermark Slots
name | Description
-- | --
\- | \-
content | \-
### WatermarkText
name | type | default | description | required
-- | -- | -- | -- | --
font-color | String | rgba(0,0,0,0.1) | \- | N
font-family | String | - | font-family configuration for watermark text | N
font-size | Number | 16 | \- | N
font-weight | String | normal | options: normal/lighter/bold/bolder | N
text | String | - | \- | N
### WatermarkImage
name | type | default | description | required
-- | -- | -- | -- | --
is-grayscale | Boolean | false | \- | N
url | String | - | \- | N
### CSS Variables
The component provides the following CSS variables, which can be used to customize styles.
Name | Default Value | Description
-- | -- | --
--watermark-left-0 | --watermark-left-0 | -
--watermark-left-25 | --watermark-left-25 | -
--watermark-left-50 | --watermark-left-50 | -
--watermark-left-75 | --watermark-left-75 | -
--watermark-top-0 | --watermark-top-0 | -
--watermark-top-25 | --watermark-top-25 | -
--watermark-top-50 | --watermark-top-50 | -
--watermark-top-75 | --watermark-top-75 | -

View File

@@ -0,0 +1,115 @@
---
title: Watermark 水印
description: 给页面的某个区域加上水印。
spline: data
isComponent: true
---
## 引入
可在 `main.ts` 或在需要使用的页面或组件中引入。
```js
import TWatermark from '@tdesign/uniapp/watermark/watermark.vue';
```
## 代码演示
### 组件类型
#### 文字水印
{{ base }}
#### 图片水印
{{ image }}
#### 图片灰阶水印
{{ gray }}
#### 多行图文水印
{{ multi-line }}
#### 多行图文灰阶水印
{{ multi-line-gray }}
#### 运动文字水印
{{ move-text }}
#### 运动图片水印
{{ move-image }}
### 不同布局的水印
通过设置 layout 使用不同的布局。
{{ layout }}
## API
### Watermark Props
名称 | 类型 | 默认值 | 描述 | 必传
-- | -- | -- | -- | --
custom-style | Object | - | 自定义样式 | N
alpha | Number | 1 | 水印整体透明度,取值范围 [0-1] | N
content | String | - | 水印所覆盖的内容节点 | N
height | Number | - | 水印高度 | N
is-repeat | Boolean | true | 水印是否重复出现 | N
layout | String | rectangular | 水印的布局方式rectangular矩形即横平竖直的水印hexagonal六边形即错位的水印。可选项rectangular/hexagonal | N
line-space | Number | 16 | 行间距,只作用在多行(`content` 配置为数组)情况下 | N
movable | Boolean | false | 水印是否可移动 | N
move-interval | Number | 3000 | 水印发生运动位移的间隙,单位:毫秒 | N
offset | Array | - | 水印在画布上绘制的水平和垂直偏移量,正常情况下水印绘制在中间位置,即 `offset = [gapX / 2, gapY / 2]`。TS 类型:`Array<number>` | N
removable | Boolean | true | 水印是否可被删除 | N
rotate | Number | -22 | 水印旋转的角度,单位 ° | N
watermark-content | Object / Array | - | 水印内容需要显示多行情况下可配置为数组。TS 类型:`WatermarkText\|WatermarkImage\|Array<WatermarkText\|WatermarkImage>` | N
width | Number | - | 水印宽度 | N
x | Number | - | 水印之间的水平间距 | N
y | Number | - | 水印之间的垂直间距 | N
z-index | Number | - | 水印元素的 `z-index`,默认值写在 CSS 中 | N
### Watermark Slots
名称 | 描述
-- | --
\- | 默认插槽,作用同 `content` 插槽
content | 自定义 `content` 显示内容
### WatermarkText
名称 | 类型 | 默认值 | 描述 | 必传
-- | -- | -- | -- | --
font-color | String | rgba(0,0,0,0.1) | 水印文本文字颜色 | N
font-family | String | - | 水印文本文字字体 | N
font-size | Number | 16 | 水印文本文字大小 | N
font-weight | String | normal | 水印文本文字粗细。可选项normal/lighter/bold/bolder | N
text | String | - | 水印文本内容 | N
### WatermarkImage
名称 | 类型 | 默认值 | 描述 | 必传
-- | -- | -- | -- | --
is-grayscale | Boolean | false | 水印图片是否需要灰阶显示 | N
url | String | - | 水印图片源地址,为了显示清楚,建议导出 2 倍或 3 倍图 | N
### CSS Variables
组件提供了下列 CSS 变量,可用于自定义样式。
名称 | 默认值 | 描述
-- | -- | --
--watermark-left-0 | --watermark-left-0 | -
--watermark-left-25 | --watermark-left-25 | -
--watermark-left-50 | --watermark-left-50 | -
--watermark-left-75 | --watermark-left-75 | -
--watermark-top-0 | --watermark-top-0 | -
--watermark-top-25 | --watermark-top-25 | -
--watermark-top-50 | --watermark-top-50 | -
--watermark-top-75 | --watermark-top-75 | -

View File

@@ -0,0 +1,82 @@
/* eslint-disable */
/**
* 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC
* */
import type { TdWatermarkProps } from './type';
export default {
/** 水印整体透明度,取值范围 [0-1] */
alpha: {
type: Number,
default: 1,
},
/** 水印所覆盖的内容节点 */
content: {
type: String,
},
/** 水印高度 */
height: {
type: Number,
},
/** 水印是否重复出现 */
isRepeat: {
type: Boolean,
default: true,
},
/** 水印的布局方式rectangular矩形即横平竖直的水印hexagonal六边形即错位的水印 */
layout: {
type: String,
default: 'rectangular' as TdWatermarkProps['layout'],
validator(val: TdWatermarkProps['layout']): boolean {
if (!val) return true;
return ['rectangular', 'hexagonal'].includes(val);
},
},
/** 行间距,只作用在多行(`content` 配置为数组)情况下 */
lineSpace: {
type: Number,
default: 16,
},
/** 水印是否可移动 */
movable: Boolean,
/** 水印发生运动位移的间隙,单位:毫秒 */
moveInterval: {
type: Number,
default: 3000,
},
/** 水印在画布上绘制的水平和垂直偏移量,正常情况下水印绘制在中间位置,即 `offset = [gapX / 2, gapY / 2]` */
offset: {
type: Array,
},
/** 水印是否可被删除 */
removable: {
type: Boolean,
default: true,
},
/** 水印旋转的角度,单位 ° */
rotate: {
type: Number,
default: -22,
},
/** 水印内容,需要显示多行情况下可配置为数组 */
watermarkContent: {
type: [Object, Array],
},
/** 水印宽度 */
width: {
type: Number,
},
/** 水印之间的水平间距 */
x: {
type: Number,
},
/** 水印之间的垂直间距 */
y: {
type: Number,
},
/** 水印元素的 `z-index`,默认值写在 CSS 中 */
zIndex: {
type: Number,
},
};

View File

@@ -0,0 +1,121 @@
/* eslint-disable */
/**
* 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC
* */
export interface TdWatermarkProps {
/**
* 水印整体透明度,取值范围 [0-1]
* @default 1
*/
alpha?: number;
/**
* 水印所覆盖的内容节点
*/
content?: string;
/**
* 水印高度
*/
height?: number;
/**
* 水印是否重复出现
* @default true
*/
isRepeat?: boolean;
/**
* 水印的布局方式rectangular矩形即横平竖直的水印hexagonal六边形即错位的水印
* @default rectangular
*/
layout?: 'rectangular' | 'hexagonal';
/**
* 行间距,只作用在多行(`content` 配置为数组)情况下
* @default 16
*/
lineSpace?: number;
/**
* 水印是否可移动
* @default false
*/
movable?: boolean;
/**
* 水印发生运动位移的间隙,单位:毫秒
* @default 3000
*/
moveInterval?: number;
/**
* 水印在画布上绘制的水平和垂直偏移量,正常情况下水印绘制在中间位置,即 `offset = [gapX / 2, gapY / 2]`
*/
offset?: Array<number>;
/**
* 水印是否可被删除
* @default true
*/
removable?: boolean;
/**
* 水印旋转的角度,单位 °
* @default -22
*/
rotate?: number;
/**
* 水印内容,需要显示多行情况下可配置为数组
*/
watermarkContent?: WatermarkText | WatermarkImage | Array<WatermarkText | WatermarkImage>;
/**
* 水印宽度
*/
width?: number;
/**
* 水印之间的水平间距
*/
x?: number;
/**
* 水印之间的垂直间距
*/
y?: number;
/**
* 水印元素的 `z-index`,默认值写在 CSS 中
*/
zIndex?: number;
}
export interface WatermarkText {
/**
* 水印文本文字颜色
* @default rgba(0,0,0,0.1)
*/
fontColor?: string;
/**
* 水印文本文字字体
* @default ''
*/
fontFamily?: string;
/**
* 水印文本文字大小
* @default 16
*/
fontSize?: number;
/**
* 水印文本文字粗细
* @default normal
*/
fontWeight?: 'normal' | 'lighter' | 'bold' | 'bolder';
/**
* 水印文本内容
* @default ''
*/
text?: string;
}
export interface WatermarkImage {
/**
* 水印图片是否需要灰阶显示
* @default false
*/
isGrayscale?: boolean;
/**
* 水印图片源地址,为了显示清楚,建议导出 2 倍或 3 倍图
* @default ''
*/
url?: string;
}

View File

@@ -0,0 +1,360 @@
import { loadImage } from '../../common/canvas/index';
import { getWindowInfo } from '../../common/utils';
const ratio = getWindowInfo().pixelRatio || 1;
// 元素中心为旋转点执行旋转
const drawRotate = (
ctx,
x,
y,
rotate,
) => {
ctx.translate(x, y);
ctx.rotate((Math.PI / 180) * Number(rotate));
ctx.translate(-x, -y);
};
// 绘制文字
const drawText = (
ctx,
x,
y,
markHeight,
text,
fontWeight,
fontSize,
fontFamily,
fillStyle,
) => {
ctx.font = `normal normal ${fontWeight} ${
fontSize * ratio
}px/${markHeight}px ${fontFamily}`;
ctx.fillStyle = fillStyle;
ctx.textAlign = 'start';
ctx.textBaseline = 'top';
ctx.fillText(text, x, y);
};
export default async function generateBase64Url(
canvas,
canvasId,
{
width,
height,
gapX,
gapY,
offsetLeft,
offsetTop,
rotate,
alpha,
watermarkContent,
lineSpace,
watermarkColor,
layout,
},
onFinish,
onFinally,
) {
const isHexagonal = layout === 'hexagonal';
let ctx;
// #ifdef MP || H5
ctx = canvas.getContext('2d', { willReadFrequently: true });
// #endif
if (!ctx) {
ctx = uni.createCanvasContext(canvasId, this);
}
if (!ctx) {
console.warn('当前环境不支持Canvas, 无法绘制水印');
onFinish('');
return;
}
let actualBackgroundSize = {
width: gapX + width,
};
const canvasWidth = (gapX + width) * ratio;
const canvasHeight = (gapY + height) * ratio;
const markWidth = width * ratio;
const markHeight = height * ratio;
const dislocationRotateX = canvasWidth;
const dislocationRotateY = canvasHeight;
const dislocationDrawX = (gapX + width) * ratio;
const dislocationDrawY = (gapY + height) * ratio;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
if (isHexagonal) {
canvas.width = canvasWidth * 2;
canvas.height = canvasHeight * 2;
// 两倍宽度+间距
actualBackgroundSize = {
width: gapX + width * 2 + width / 2,
};
}
ctx.globalAlpha = alpha;
// h5需要全局缩放
// #ifdef H5 || APP-PLUS
ctx.scale(1 / ratio, 1 / ratio);
// #endif
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, markWidth, markHeight);
ctx.translate(offsetLeft * ratio, offsetTop * ratio);
const contents = Array.isArray(watermarkContent)
? watermarkContent
: [{ ...watermarkContent }];
let top = 0;
// 预处理
contents.forEach((item) => {
item.top = top;
if (item.url) {
top += height;
} else if (item.text) {
top += lineSpace;
}
});
// 绘制水印内容
const renderWatermarkItem = async (
item,
offsetX = 0,
offsetY = 0,
rotateX = 0,
rotateY = 0,
) => {
if (item.url) {
const { url, isGrayscale = false } = item;
const img = await loadImage({
canvas,
src: url,
});
ctx.save?.();
drawRotate(ctx, rotateX, rotateY, rotate);
// TODO其他技术栈修复了「灰度效果只影响图片不影响文字」的bug因为小程序不能创建临时canvas暂时没有想到比较优雅的解决方案
if (isGrayscale) {
// #ifdef APP-PLUS
ctx.drawImage(
img,
offsetX,
offsetY + item.top * ratio,
width * ratio / 2,
height * ratio / 2,
);
// #endif
// #ifndef APP-PLUS
ctx.drawImage(
img,
offsetX,
offsetY + item.top * ratio,
width * ratio,
height * ratio,
);
// #endif
const imgData = await getImageData(ctx, {
x: 0,
y: 0,
width,
height,
});
const pixels = imgData.data;
for (let i = 0; i < pixels.length; i += 4) {
const lightness = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
pixels[i] = lightness;
pixels[i + 1] = lightness;
pixels[i + 2] = lightness;
}
await putImageData(ctx, imgData, {
x: 0,
y: 0,
});
} else {
// #ifdef APP-PLUS
ctx.drawImage(
img,
offsetX,
(offsetY + item.top * ratio),
width * ratio / 2,
height * ratio / 2,
);
// #endif
// #ifndef APP-PLUS
ctx.drawImage(
img,
offsetX,
offsetY + item.top * ratio,
width * ratio,
height * ratio,
);
// #endif
}
ctx.restore?.();
return;
}
if (item.text) {
const {
text,
fontSize = 16,
fontFamily = 'normal',
fontWeight = 'normal',
} = item;
const fillStyle = item?.fontColor || watermarkColor;
ctx.save?.();
drawRotate(ctx, rotateX, rotateY, rotate);
drawText(
ctx,
offsetX,
offsetY + item.top * ratio,
markHeight,
text,
fontWeight,
fontSize,
fontFamily,
fillStyle,
);
ctx.restore?.();
}
};
// 矩形水印
for (const item of contents) {
await renderWatermarkItem(item, 0, 0, 0, 0);
}
// 六边形水印
if (isHexagonal) {
for (const item of contents) {
await renderWatermarkItem(
item,
dislocationDrawX,
dislocationDrawY,
dislocationRotateX,
dislocationRotateY,
);
}
}
// #ifdef APP-PLUS
ctx.draw();
// #endif
// 没有图片
const canvasUrl = await exportCanvasImage.call(this, canvas, canvasId);
onFinish(canvasUrl, actualBackgroundSize, ratio);
onFinally?.();
}
// 跨平台 Canvas 导出方法
export function exportCanvasImage(canvas, canvasId) {
return new Promise((resolve, reject) => {
let parsed = false;
// #ifdef H5 || MP
resolve(canvas.toDataURL('image/png'));
parsed = true;
// #endif
if (parsed) return;
const query = uni.createSelectorQuery().in(this);
query
.select(`#${canvasId}`)
.fields({ node: true, size: true })
.exec(async (res) => {
if (!res[0]?.node) {
console.error('Canvas node not found');
return;
}
uni.canvasToTempFilePath({
// #ifdef MP
canvas: res[0].node,
// #endif
// #ifndef MP
canvasId,
// #endif
success: res => resolve(res.tempFilePath),
fail: reject,
});
});
});
}
async function getImageData(ctx, options) {
let result;
// #ifdef H5 || MP
result = ctx.getImageData(options.x, options.y, options.width, options.height);
// #endif
if (!result) {
result = new Promise((resolve) => {
uni.canvasGetImageData({
canvasId: options.canvasId,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
success: (res) => {
// 小程序/App 返回的数据结构需要转换
resolve({
data: new Uint8ClampedArray(res.data),
width: res.width,
height: res.height,
});
},
}, this);
});
}
return result;
}
async function putImageData(ctx, imageData, options) {
let result;
// #ifdef H5 || MP
ctx.putImageData(imageData, options.x, options.y);
result = Promise.resolve();
// #endif
if (!result) {
result = new Promise((resolve, reject) => {
uni.canvasPutImageData({
canvasId: options.canvasId,
x: options.x,
y: options.y,
width: imageData.width,
height: imageData.height,
data: imageData.data,
success: resolve,
fail: reject,
}, this);
});
}
return result;
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable no-nested-ternary */
export default function randomMovingStyle() {
const align = Math.floor(Math.random() * 4);
const p1 = Math.floor(Math.random() * 70) + 30;
const leftTopLimit = 0;
const bottomLimit = 95;
const rightLimit = 90;
const left0 = align === 1 ? rightLimit : align === 3 ? leftTopLimit : p1;
const left25 = align === 0 ? rightLimit : align === 2 ? leftTopLimit : 100 - p1;
const left50 = align === 1 ? leftTopLimit : align === 3 ? rightLimit : 100 - p1;
const left75 = align === 0 ? leftTopLimit : align === 2 ? rightLimit : p1;
const top0 = align === 0 ? leftTopLimit : align === 2 ? bottomLimit : p1;
const top25 = align === 1 ? bottomLimit : align === 3 ? leftTopLimit : p1;
const top50 = align === 0 ? bottomLimit : align === 2 ? leftTopLimit : 100 - p1;
const top75 = align === 1 ? leftTopLimit : align === 3 ? bottomLimit : 100 - p1;
return {
left0: `${left0}%`,
left25: `${left25}%`,
left50: `${left50}%`,
left75: `${left75}%`,
top0: `${top0}%`,
top25: `${top25}%`,
top50: `${top50}%`,
top75: `${top75}%`,
};
}

View File

@@ -0,0 +1,28 @@
.t-watermark {
position: relative;
overflow: hidden;
width: 100%;
}
@keyframes watermark {
0% {
left: var(--watermark-left-0);
top: var(--watermark-top-0);
}
25% {
left: var(--watermark-left-25);
top: var(--watermark-top-25);
}
50% {
left: var(--watermark-left-50);
top: var(--watermark-top-50);
transform: translateX(-100%);
}
75% {
left: var(--watermark-left-75);
top: var(--watermark-top-75);
}
100% {
left: var(--watermark-left-0);
top: var(--watermark-top-0);
}
}

View File

@@ -0,0 +1,200 @@
<template>
<view
:class="classPrefix + ' ' + tClass"
:style="tools._style([customStyle])"
>
<block v-if="content">
{{ content }}
</block>
<slot name="content" />
<slot />
<canvas
:id="canvasId"
:canvas-id="canvasId"
type="2d"
:style="canvasStyle"
/>
<view
:class="movable ? 'watermark-move' : ''"
:style="tools._style(watermarkStyle)"
/>
</view>
</template>
<script>
import tools from '../common/utils.wxs';
import watermarkProps from './props';
import { prefix } from '../common/config';
import { uniComponent } from '../common/src/index';
import { appBaseInfo, nextTick } from '../common/utils';
import generateBase64Url from './utils/generateBase64Url';
import randomMovingStyle from './utils/randomMovingStyle';
const name = `${prefix}-watermark`;
export default uniComponent({
name,
options: {
styleIsolation: 'shared',
},
externalClasses: [`${prefix}-class`],
props: {
...watermarkProps,
},
data() {
return {
classPrefix: name,
tools,
watermarkStyle: {},
initialed: false,
canvasId: `watermark-canvas-${Math.random().toString(36)
.slice(2, 11)}`,
};
},
computed: {
canvasStyle() {
let result = 'width: 100px; height: 100px;';
let shouldHide = true;
// #ifdef APP-PLUS || MP-ALIPAY
if (!this.initialed) {
shouldHide = false;
}
// #endif
if (shouldHide) {
result += 'display: none;';
}
return result;
},
},
watch: {
watermarkContent: 'renderWatermark',
movable: 'renderWatermark',
rotate: 'renderWatermark',
x: 'renderWatermark',
y: 'renderWatermark',
width: 'renderWatermark',
height: 'renderWatermark',
alpha: 'renderWatermark',
lineSpace: 'renderWatermark',
moveInterval: 'renderWatermark',
zIndex: 'renderWatermark',
offset: 'renderWatermark',
removable: 'renderWatermark',
isRepeat: 'renderWatermark',
layout: 'renderWatermark',
},
async mounted() {
await nextTick();
this.renderWatermark();
},
methods: {
watermarkColor() {
return appBaseInfo.theme === 'dark'
? 'rgba(238, 238, 238, 0.1)'
: 'rgba(0, 0, 0, 0.1)';
},
renderWatermark() {
const query = uni.createSelectorQuery().in(this);
query
.select(`#${this.canvasId}`)
.fields({ node: true, size: true })
.exec(async (res) => {
if (!res[0]?.node) {
console.error('Canvas node not found');
return;
}
const canvas = res[0].node;
const gapX = this.movable ? 0 : this.x;
const gapY = this.movable ? 0 : this.y;
const offset = this.offset || [];
const offsetLeft = offset[0] || gapX / 2;
const offsetTop = offset[1] || gapY / 2;
const bgImageOptions = {
width: this.width,
height: this.height,
rotate: this.movable ? 0 : this.rotate,
lineSpace: this.lineSpace,
alpha: this.alpha,
gapX,
gapY,
watermarkContent: this.watermarkContent,
offsetLeft,
offsetTop,
watermarkColor: this.watermarkColor(),
layout: this.layout,
};
generateBase64Url.call(
this,
canvas,
this.canvasId,
bgImageOptions,
(base64Url, backgroundSize) => {
let animationVars = {};
if (this.movable) {
const {
left0,
left25,
left50,
left75,
top0,
top25,
top50,
top75,
} = randomMovingStyle();
animationVars = {
'--watermark-left-0': left0,
'--watermark-left-25': left25,
'--watermark-left-50': left50,
'--watermark-left-75': left75,
'--watermark-top-0': top0,
'--watermark-top-25': top25,
'--watermark-top-50': top50,
'--watermark-top-75': top75,
'--watermark-animation-duration': `${
(this.moveInterval * 4) / 60
}s`,
};
}
this.watermarkStyle = {
zIndex: this.zIndex,
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
width: '100%',
height: '100%',
backgroundSize: `${
backgroundSize?.width || gapX + this.width
}px`,
pointerEvents: 'none',
backgroundRepeat: this.movable ? 'no-repeat' : 'repeat',
backgroundImage: `url('${base64Url}')`,
...animationVars,
};
},
() => {
this.initialed = true;
},
).catch((e) => {
console.log('e', e);
});
});
},
},
});
</script>
<style scoped>
@import './watermark.css';
/* diff with td-mini */
.watermark-move {
animation: watermark var(--watermark-animation-duration, 100s) linear infinite;
}
</style>