diff --git a/examples/watermark/demos/base.vue b/examples/watermark/demos/base.vue new file mode 100644 index 000000000..7369213aa --- /dev/null +++ b/examples/watermark/demos/base.vue @@ -0,0 +1,13 @@ + diff --git a/examples/watermark/demos/graylevel.vue b/examples/watermark/demos/graylevel.vue new file mode 100644 index 000000000..e3c883292 --- /dev/null +++ b/examples/watermark/demos/graylevel.vue @@ -0,0 +1,15 @@ + diff --git a/examples/watermark/demos/image.vue b/examples/watermark/demos/image.vue new file mode 100644 index 000000000..90167e66f --- /dev/null +++ b/examples/watermark/demos/image.vue @@ -0,0 +1,16 @@ + +Footer diff --git a/examples/watermark/demos/movingImage.vue b/examples/watermark/demos/movingImage.vue new file mode 100644 index 000000000..b396a4e26 --- /dev/null +++ b/examples/watermark/demos/movingImage.vue @@ -0,0 +1,14 @@ + diff --git a/examples/watermark/demos/movingText.vue b/examples/watermark/demos/movingText.vue new file mode 100644 index 000000000..a2d1ebc85 --- /dev/null +++ b/examples/watermark/demos/movingText.vue @@ -0,0 +1,14 @@ + diff --git a/examples/watermark/demos/multiline.vue b/examples/watermark/demos/multiline.vue new file mode 100644 index 000000000..bb656d2a1 --- /dev/null +++ b/examples/watermark/demos/multiline.vue @@ -0,0 +1,16 @@ + diff --git a/examples/watermark/watermark.md b/examples/watermark/watermark.md new file mode 100644 index 000000000..47c794f68 --- /dev/null +++ b/examples/watermark/watermark.md @@ -0,0 +1,40 @@ +:: BASE_DOC :: + +## API + +### Watermark Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +alpha | Number | 1 | 水印整体透明度,取值范围 [0-1] | N +content | String / Slot / Function | - | 水印所覆盖的内容节点。TS 类型:`string | TNode`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/src/common.ts) | N +default | String / Slot / Function | - | 水印所覆盖的内容节点,同 `content`。TS 类型:`string | TNode`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/src/common.ts) | N +height | Number | - | 水印高度 | N +isRepeat | Boolean | true | 水印是否重复出现 | N +lineSpace | Number | 16 | 行间距,只作用在多行(`content` 配置为数组)情况下 | N +movable | Boolean | false | 水印是否可移动 | N +moveInterval | Number | 3000 | 水印发生运动位移的间隙,单位:毫秒 | N +offset | Array | - | 水印在画布上绘制的水平和垂直偏移量,正常情况下水印绘制在中间位置,即 `offset = [gapX / 2, gapY / 2]`。TS 类型:`Array` | N +removable | Boolean | true | 水印是否可被删除,默认会开启水印节点防删 | N +rotate | Number | -22 | 水印旋转的角度,单位 ° | N +watermarkContent | Object / Array | - | 水印内容,需要显示多行情况下可配置为数组。TS 类型:`WatermarkText|WatermarkImage|Array` | N +width | Number | - | 水印宽度 | N +x | Number | - | 水印之间的水平间距 | N +y | Number | - | 水印之间的垂直间距 | N +zIndex | Number | - | 水印元素的 `z-index`,默认值写在 CSS 中 | N + +### WatermarkText + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +fontColor | String | rgba(0,0,0,0.1) | 水印文本文字颜色 | N +fontSize | Number | 16 | 水印文本文字大小 | N +fontWeight | String | normal | 水印文本文字粗细。可选项:normal/lighter/bold/bolder | N +text | String | - | 水印文本内容 | N + +### WatermarkImage + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +isGrayscale | Boolean | false | 水印图片是否需要灰阶显示 | N +url | String | - | 水印图片源地址,为了显示清楚,建议导出 2 倍或 3 倍图 | N \ No newline at end of file diff --git a/site/site.config.mjs b/site/site.config.mjs index ec1a63baa..5d700fbc4 100644 --- a/site/site.config.mjs +++ b/site/site.config.mjs @@ -506,6 +506,14 @@ const docs = [ component: () => import('@/examples/tree/tree.md'), componentEn: () => import('@/examples/tree/tree.en-US.md'), }, + { + title: 'Watermark 水印', + titleEn: 'Watermark', + name: 'watermark', + path: '/vue/components/watermark', + component: () => import('@/examples/watermark/watermark.md'), + componentEn: () => import('@/examples/watermark/watermark.en-US.md'), + }, ], }, { diff --git a/src/components.ts b/src/components.ts index ddfd70a46..0e23a1230 100644 --- a/src/components.ts +++ b/src/components.ts @@ -56,6 +56,7 @@ export * from './table'; export * from './tag'; export * from './tooltip'; export * from './tree'; +export * from './watermark'; export * from './collapse'; // 消息提醒 diff --git a/src/watermark/hooks.ts b/src/watermark/hooks.ts new file mode 100644 index 000000000..1a59c6434 --- /dev/null +++ b/src/watermark/hooks.ts @@ -0,0 +1,77 @@ +import type { ComponentPublicInstance, Ref } from '@vue/composition-api'; +import { + unref, watch, getCurrentScope, onScopeDispose, +} from '@vue/composition-api'; + +export const defaultWindow = typeof window !== 'undefined' ? window : undefined; +export interface ConfigurableWindow { + window?: Window; +} +// eslint-disable-next-line no-undef +export interface MutationObserverOptions extends MutationObserverInit, ConfigurableWindow {} +export type MaybeRef = T | Ref; +export type VueInstance = ComponentPublicInstance; +export type MaybeElementRef = MaybeRef; +export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null; +export type UnRefElementReturn = T extends VueInstance + ? Exclude + : T | undefined; + +export type Fn = () => void; + +export function unrefElement(elRef: MaybeElementRef): UnRefElementReturn { + const plain = unref(elRef); + return (plain as VueInstance)?.$el ?? plain; +} +export function tryOnScopeDispose(fn: Fn) { + if (getCurrentScope()) { + onScopeDispose(fn); + return true; + } + return false; +} + +export function useMutationObserver( + target: MaybeElementRef, + // eslint-disable-next-line no-undef + callback: MutationCallback, + options: MutationObserverOptions = {}, +) { + const { window = defaultWindow, ...mutationOptions } = options; + let observer: MutationObserver | undefined; + const isSupported = window && 'MutationObserver' in window; + + const cleanup = () => { + if (observer) { + observer.disconnect(); + observer = undefined; + } + }; + + const stopWatch = watch( + () => unrefElement(target), + (el) => { + cleanup(); + + if (isSupported && window && el) { + observer = new MutationObserver(callback); + observer.observe(el, mutationOptions); + } + }, + { immediate: true }, + ); + + const stop = () => { + cleanup(); + stopWatch(); + }; + + tryOnScopeDispose(stop); + + return { + isSupported, + stop, + }; +} + +export type UseMutationObserverReturn = ReturnType; diff --git a/src/watermark/index.ts b/src/watermark/index.ts new file mode 100644 index 000000000..f2c73b514 --- /dev/null +++ b/src/watermark/index.ts @@ -0,0 +1,6 @@ +import _Watermark from './watermark'; + +import withInstall from '../utils/withInstall'; + +export const Watermark = withInstall(_Watermark); +export default Watermark; diff --git a/src/watermark/watermark.tsx b/src/watermark/watermark.tsx new file mode 100644 index 000000000..35125e086 --- /dev/null +++ b/src/watermark/watermark.tsx @@ -0,0 +1,121 @@ +import { + defineComponent, computed, ref, onMounted, +} from '@vue/composition-api'; +import generateBase64Url from '../_common/js/watermark/generateBase64Url'; +import randomMovingStyle from '../_common/js/watermark/randomMovingStyle'; +import injectStyle from '../_common/js/utils/injectStyle'; +import { usePrefixClass } from '../hooks/useConfig'; +import { useContent } from '../hooks/tnode'; +import { useMutationObserver } from './hooks'; +import props from './props'; + +export default defineComponent({ + name: 'TWatermark', + props, + setup(props) { + const backgroundImage = ref(''); + const watermarkRef = ref(); + const parent = ref(); + const gapX = computed(() => (props.movable ? 0 : props.x)); + const gapY = computed(() => (props.movable ? 0 : props.y)); + const rotate = computed(() => (props.movable ? 0 : props.rotate)); + + const backgroundRepeat = computed(() => { + if (props.movable) { + return 'no-repeat'; + } + return props.isRepeat ? 'repeat' : 'no-repeat'; + }); + + const offsetLeft = computed(() => props.offset?.[0] || gapX.value / 2); + + const offsetTop = computed(() => props.offset?.[1] || gapY.value / 2); + + useMutationObserver( + watermarkRef, + (mutations) => { + if (props.removable) return; + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + const removeNodes = mutation.removedNodes; + removeNodes.forEach((node) => { + watermarkRef.value.appendChild(node); + }); + } + }); + }, + { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }, + ); + + onMounted(() => { + generateBase64Url( + { + width: props.width, + height: props.height, + rotate: rotate.value, + lineSpace: props.lineSpace, + alpha: props.alpha, + gapX: gapX.value, + gapY: gapY.value, + watermarkContent: props.watermarkContent, + offsetLeft: offsetLeft.value, + offsetTop: offsetTop.value, + }, + (base64Url) => { + backgroundImage.value = base64Url; + }, + ); + parent.value = watermarkRef.value?.parentElement; + const keyframesStyle = randomMovingStyle(); + injectStyle(keyframesStyle); + }); + + return { + gapX, + gapY, + backgroundRepeat, + backgroundImage, + }; + }, + render() { + const COMPONENT_NAME = usePrefixClass('watermark'); + const watermarkRef = ref(); + const renderContent = useContent(); + + return ( +
+ {renderContent('default', 'content')} +
+
+ ); + }, +}); diff --git a/test/ssr/__snapshots__/ssr.test.js.snap b/test/ssr/__snapshots__/ssr.test.js.snap index 88544d3dc..8411037a4 100644 --- a/test/ssr/__snapshots__/ssr.test.js.snap +++ b/test/ssr/__snapshots__/ssr.test.js.snap @@ -21370,3 +21370,45 @@ exports[`ssr snapshot test renders ./examples/upload/demos/table.vue correctly 1
`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/base.vue correctly 1`] = ` +
+
+
+
+`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/graylevel.vue correctly 1`] = ` +
+
+
+
+`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/image.vue correctly 1`] = ` +
+
+
+
+`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/movingImage.vue correctly 1`] = ` +
+
+
+
+`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/movingText.vue correctly 1`] = ` +
+
+
+
+`; + +exports[`ssr snapshot test renders ./examples/watermark/demos/multiline.vue correctly 1`] = ` +
+
+
+
+`;