diff --git a/packages/vant/src/watermark/README.md b/packages/vant/src/watermark/README.md new file mode 100644 index 000000000..b7c69b994 --- /dev/null +++ b/packages/vant/src/watermark/README.md @@ -0,0 +1,119 @@ +# Watermark + +### Intro + +Add watermark for page. + +### Install + +Register component globally via `app.use`, refer to [Component Registration](#/en-US/advanced-usage#zu-jian-zhu-ce) for more registration ways. + +```js +import { createApp } from 'vue'; +import { Watermark } from 'vant'; + +const app = createApp(); +app.use(Watermark); +``` + +## Usage + +### Basic Usage + +```html + + + + + +``` + +### Custom Gap + +Use `gapX` `gapY` attributes to control the gap between two watermark slice. + +```html + +``` + +### Custom Opacity + +Use `opacity` attribute to control the entirety opacity. + +```html + +``` + +### Custom Rotate + +Use `rotate` attribute to control the rotate of watermark. Default value is `-22`. + +```html + +``` + +### Display Range + +Use the `fullPage` attribute to control the display range of the watermark. + +```html + + +``` + +### HTML Watermark + +Use the `default slot` to pass HTML directly. Inline styles are supported, and self-closing tags are not supported. + +```html + +
+

Vant watermark

+
+
+``` + +## API + +### Props + +| Attribute | Description | Type | Default | +| --- | --- | --- | --- | +| width | Watermark width | _number_ | 100 | +| height | Watermark height | _number_ | 100 | +| zIndex | Watermark's z-index | _number_ | 100 | +| content | Text watermark content | _string_ | - | +| image | Image watermark content. If `content` and `image` are passed at the same time, use the `image` watermark first | _string_ | - | +| fullPage | Whether to display the watermark in full screen | _boolean_ | true | +| gapX | Horizontal spacing between watermarks | _number_ | 0 | +| gapY | Vertical spacing between watermarks | _number_ | 0 | +| fontColor | Color of text watermark | _string_ | #dcdee0 | +| opacity | opacity of watermark | _number_ | 1 | + +### Slots + +| Attribute | Description | +| --- | --- | +| default | Content of HTML watermark. Inline styles are supported, and self-closing tags are not supported. This slot is invalid if `content` or `image` is passed | + +### Types + +The component exports the following type definitions: + +```ts +import type { WaterProps } from 'vant'; +``` diff --git a/packages/vant/src/watermark/README.zh-CN.md b/packages/vant/src/watermark/README.zh-CN.md new file mode 100644 index 000000000..db7725199 --- /dev/null +++ b/packages/vant/src/watermark/README.zh-CN.md @@ -0,0 +1,119 @@ +# Watermark 水印 + +### 介绍 + +页面上添加特定的文字或图案,可用于防止信息盗用 + +### 引入 + +通过以下方式来全局注册组件,更多注册方式请参考[组件注册](#/zh-CN/advanced-usage#zu-jian-zhu-ce)。 + +```js +import { createApp } from 'vue'; +import { Watermark } from 'vant'; + +const app = createApp(); +app.use(Watermark); +``` + +## 代码演示 + +### 基础用法 + +```html + + + + + +``` + +### 自定义间隔 + +通过 `gapX` `gapY` 属性来控制重复水印之间的间隔。 + +```html + +``` + +### 自定义透明度 + +通过 `opacity` 属性来控制水印的整体透明度。 + +```html + +``` + +### 自定义倾斜角度 + +通过 `rotate` 属性来控制水印的倾斜角度,默认值为`-22`。 + +```html + +``` + +### 显示范围 + +通过 `fullPage` 属性来控制水印的显示范围。 + +```html + + +``` + +### HTML 水印 + +通过默认插槽可以直接传入 HTML,HTML 样式仅支持行内样式同时不支持传入自闭合标签。 + +```html + +
+

Vant watermark

+
+
+``` + +## API + +### Props + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| width | 水印宽度 | _number_ | 100 | +| height | 水印高度 | _number_ | 100 | +| zIndex | 水印的 z-index | _number_ | 100 | +| content | 文字水印的内容 | _string_ | - | +| image | 图片水印的内容,如果与 content 同时传入,优先使用图片水印 | _string_ | - | +| fullPage | 水印是否全屏显示 | _boolean_ | false | +| gapX | 水印水平间隔 | _number_ | 0 | +| gapY | 水印垂直间隔 | _number_ | 0 | +| fontColor | 文字水印的颜色 | _string_ | #dcdee0 | +| opacity | 水印的透明度 | _number_ | 1 | + +### Slots + +| 名称 | 说明 | +| --- | --- | +| default | HTML 水印的内容,仅支持行内样式同时不支持传入自闭合标签,存在 content 或 image 时此插槽无效 | + +### 类型定义 + +组件导出以下类型定义: + +```ts +import type { WaterProps } from 'vant'; +``` diff --git a/packages/vant/src/watermark/Watermark.tsx b/packages/vant/src/watermark/Watermark.tsx new file mode 100644 index 000000000..fa4cfd707 --- /dev/null +++ b/packages/vant/src/watermark/Watermark.tsx @@ -0,0 +1,184 @@ +import { + defineComponent, + nextTick, + onUnmounted, + ref, + watch, + watchEffect, + type ExtractPropTypes, +} from 'vue'; +import { + createNamespace, + makeNumberProp, + makeNumericProp, + makeStringProp, + truthProp, +} from '../utils'; + +const [name, bem] = createNamespace('watermark'); + +export const watermarkProps = { + width: makeNumberProp(100), + height: makeNumberProp(100), + rotate: makeNumericProp(-22), + zIndex: makeNumberProp(100), + content: String, + image: String, + fullPage: truthProp, + gapX: makeNumberProp(0), + gapY: makeNumberProp(0), + fontColor: makeStringProp('#dcdee0'), + opacity: makeNumberProp(1), +}; + +export type WatermarkProps = ExtractPropTypes; + +export default defineComponent({ + name, + + props: watermarkProps, + + setup(props, { slots }) { + const svgElRef = ref(); + + const watermarkUrl = ref(''); + const imageBase64 = ref(''); + const renderWatermark = () => { + const svgInner = () => { + if (props.image) { + return ( + + ); + } + + return ( + +
+ {props.content ? ( + + {props.content} + + ) : ( + slots?.default?.() + )} +
+
+ ); + }; + + const svgWidth = props.width + props.gapX; + const svgHeight = props.height + props.gapY; + + return ( + + {svgInner()} + + ); + }; + + const makeImageToBase64 = (url: string) => { + const canvas = document.createElement('canvas'); + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.referrerPolicy = 'no-referrer'; + image.onload = () => { + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(image, 0, 0); + imageBase64.value = canvas.toDataURL(); + }; + image.src = url; + }; + + const makeSvgToBlobUrl = (svgStr: string) => { + const svgBlob = new Blob([svgStr], { + type: 'image/svg+xml;charset=utf-8', + }); + return URL.createObjectURL(svgBlob); + }; + + watchEffect(() => { + if (props.image) { + makeImageToBase64(props.image); + } + }); + + watch( + () => [ + imageBase64.value, + props.content, + props.fontColor, + props.height, + props.width, + props.rotate, + props.gapX, + props.gapY, + ], + () => { + // 路径为 renderWatermark渲染的实际HTML => SVG字符串转换为blob图片 => 放到background-image中。 + nextTick(() => { + if (svgElRef.value) { + if (watermarkUrl.value) { + URL.revokeObjectURL(watermarkUrl.value); + } + watermarkUrl.value = makeSvgToBlobUrl(svgElRef.value.innerHTML); + } + }); + }, + { + immediate: true, + } + ); + + onUnmounted(() => { + if (watermarkUrl.value) { + URL.revokeObjectURL(watermarkUrl.value); + } + }); + + return () => ( +
+
+ {renderWatermark()} +
+
+ ); + }, +}); diff --git a/packages/vant/src/watermark/demo/index.vue b/packages/vant/src/watermark/demo/index.vue new file mode 100644 index 000000000..4301e57c8 --- /dev/null +++ b/packages/vant/src/watermark/demo/index.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/vant/src/watermark/index.less b/packages/vant/src/watermark/index.less new file mode 100644 index 000000000..2c217b1c3 --- /dev/null +++ b/packages/vant/src/watermark/index.less @@ -0,0 +1,8 @@ +.van-watermark { + background-repeat: repeat; + height: 100%; + width: 100%; + left: 0; + top: 0; + pointer-events: none; +} diff --git a/packages/vant/src/watermark/index.ts b/packages/vant/src/watermark/index.ts new file mode 100644 index 000000000..54927ed2e --- /dev/null +++ b/packages/vant/src/watermark/index.ts @@ -0,0 +1,13 @@ +import { withInstall } from '../utils'; +import _Watermark from './Watermark'; + +export const Watermark = withInstall(_Watermark); +export default Watermark; +export { watermarkProps } from './Watermark'; +export type { WatermarkProps } from './Watermark'; + +declare module 'vue' { + export interface GlobalComponents { + VanWatermark: typeof Watermark; + } +} diff --git a/packages/vant/src/watermark/test/__snapshots__/index.spec.ts.snap b/packages/vant/src/watermark/test/__snapshots__/index.spec.ts.snap new file mode 100644 index 000000000..8b4e1e2e8 --- /dev/null +++ b/packages/vant/src/watermark/test/__snapshots__/index.spec.ts.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`watermark should render content 1`] = ` +
+
+ + +
+ + Vant + +
+
+
+
+
+`; + +exports[`watermark should render html 1`] = ` +
+
+ + +
+ vant watermark test +
+
+
+
+
+`; + +exports[`watermark should render image 1`] = ` +
+
+ + + + +
+
+`; + +exports[`watermark test false value fullPage 1`] = ` +
+
+ + +
+ vant watermark test +
+
+
+
+
+`; + +exports[`watermark test width, height, rotate, zIndex 1`] = ` +
+
+ + +
+ vant watermark test +
+
+
+
+
+`; diff --git a/packages/vant/src/watermark/test/index.spec.ts b/packages/vant/src/watermark/test/index.spec.ts new file mode 100644 index 000000000..dba42a008 --- /dev/null +++ b/packages/vant/src/watermark/test/index.spec.ts @@ -0,0 +1,107 @@ +// @ts-nocheck +import { Watermark } from '..'; +import { mount } from '../../../test'; + +describe('watermark', () => { + beforeEach(() => { + const createElement = document.createElement.bind(document); + document.createElement = (tagName: string) => { + if (tagName === 'canvas') { + return { + ...createElement(tagName), + getContext: () => { + () => {}; + }, + toDataURL: () => 'base64Url', + }; + } + return createElement(tagName); + }; + global.URL.createObjectURL = jest.fn(() => 'run to here'); + global.Image = class { + crossOrigin = 'anonymous'; + + referrerPolicy = 'no-referrer'; + + naturalWidth = 800; + + naturalHeight = 550; + + onload: () => void = () => {}; + + // just mock to trigge onload + _src = ''; + + get src() { + return this._src; + } + + set src(val) { + this._src = val; + this.onload(); + } + }; + }); + + test('should render content', () => { + const wrapper = mount(Watermark, { + props: { + content: 'Vant', + fontColor: 'red', + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + test('should render image', () => { + const wrapper = mount(Watermark, { + props: { + content: 'Vant', + image: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg', + opacity: 0.5, + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + test('should render html', () => { + const wrapper = mount(Watermark, { + slots: { + default: () => 'vant watermark test', + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + test('test width, height, rotate, zIndex', () => { + const wrapper = mount(Watermark, { + props: { + width: 20, + height: 20, + rotate: 20, + zIndex: 200, + }, + slots: { + default: () => 'vant watermark test', + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + test('test false value fullPage', () => { + const wrapper = mount(Watermark, { + props: { + fullPage: false, + }, + slots: { + default: () => 'vant watermark test', + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/packages/vant/vant.config.mjs b/packages/vant/vant.config.mjs index e834ef52d..be48f9228 100644 --- a/packages/vant/vant.config.mjs +++ b/packages/vant/vant.config.mjs @@ -344,6 +344,10 @@ location.href = location.href.replace('youzan.github.io', 'vant-ui.github.io'); path: 'text-ellipsis', title: 'TextEllipsis 文本省略', }, + { + path: 'watermark', + title: 'Watermark 水印', + }, ], }, { @@ -784,6 +788,10 @@ location.href = location.href.replace('youzan.github.io', 'vant-ui.github.io'); path: 'text-ellipsis', title: 'TextEllipsis', }, + { + path: 'watermark', + title: 'Watermark', + }, ], }, {