diff --git a/theme/src/client/components/Page.vue b/theme/src/client/components/Page.vue
index 83ad2320..965a996b 100644
--- a/theme/src/client/components/Page.vue
+++ b/theme/src/client/components/Page.vue
@@ -11,6 +11,7 @@ import PageFooter from './PageFooter.vue'
import PageMeta from './PageMeta.vue'
import EncryptPage from './EncryptPage.vue'
import TransitionFadeSlideY from './TransitionFadeSlideY.vue'
+import Watermark from './Watermark.vue'
const { hasSidebar, hasAside } = useSidebar()
const isDark = useDarkMode()
@@ -56,8 +57,10 @@ onContentUpdated(() => zoom?.refresh())
-
-
+
+
+
+
diff --git a/theme/src/client/components/Watermark.vue b/theme/src/client/components/Watermark.vue
new file mode 100644
index 00000000..3c07f80d
--- /dev/null
+++ b/theme/src/client/components/Watermark.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
diff --git a/theme/src/client/composables/waterMark.ts b/theme/src/client/composables/waterMark.ts
new file mode 100644
index 00000000..3a404074
--- /dev/null
+++ b/theme/src/client/composables/waterMark.ts
@@ -0,0 +1,191 @@
+import { computed, nextTick, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
+import { isLinkHttp } from 'vuepress/shared'
+import { usePageData, usePageFrontmatter, useRoute, useSiteLocaleData, withBase } from 'vuepress/client'
+import type { PlumeThemePageData, PlumeThemePageFrontmatter, WatermarkOptions } from '../../shared/index.js'
+import { toArray } from '../utils/base.js'
+import { useDarkMode } from './darkMode.js'
+import { useThemeLocaleData } from './themeData.js'
+
+const defaultWatermarkOptions: WatermarkOptions = {
+ global: true,
+ matches: [],
+ width: 150,
+ height: 100,
+ rotate: -22,
+ fullPage: true,
+ gapX: 20,
+ gapY: 20,
+ opacity: 0.1,
+ onlyPrint: false,
+}
+
+export function useWaterMark() {
+ const isDark = useDarkMode()
+ const site = useSiteLocaleData()
+ const theme = useThemeLocaleData()
+ const page = usePageData()
+ const frontmatter = usePageFrontmatter()
+ const route = useRoute()
+
+ const watermark = computed(() => {
+ if (!theme.value.watermark)
+ return {}
+
+ const pageWatermark = typeof frontmatter.value.watermark === 'object' ? frontmatter.value.watermark : {}
+ const content = site.value.title || theme.value.avatar?.name
+
+ return {
+ content,
+ ...defaultWatermarkOptions,
+ ...theme.value.watermark === true ? {} : theme.value.watermark,
+ ...pageWatermark,
+ }
+ })
+
+ const enableWatermark = computed(() => {
+ if (!theme.value.watermark)
+ return false
+
+ const pageWatermark = frontmatter.value.watermark
+ if (watermark.value.global)
+ return pageWatermark !== false
+
+ if (pageWatermark)
+ return true
+
+ const matches = toArray(watermark.value.matches!)
+ return matches.some(toMatch)
+ })
+
+ function toMatch(match: string) {
+ const relativePath = page.value.filePathRelative || ''
+ if (match[0] === '^') {
+ const regex = new RegExp(match)
+ return regex.test(route.path) || (relativePath && regex.test(relativePath))
+ }
+ if (match.endsWith('.md'))
+ return !!relativePath && relativePath.endsWith(match)
+
+ return route.path.startsWith(match) || relativePath.startsWith(match)
+ }
+
+ const isFullPage = computed(() => !!watermark.value.fullPage)
+ const onlyPrint = computed(() => !!watermark.value.onlyPrint)
+
+ const svgRect = computed(() => ({
+ width: watermark.value.width!,
+ height: watermark.value.height!,
+ gapX: watermark.value.gapX!,
+ gapY: watermark.value.gapY!,
+ svgWidth: watermark.value.width! + watermark.value.gapX!,
+ svgHeight: watermark.value.height! + watermark.value.gapY!,
+ opacity: watermark.value.opacity,
+ }))
+
+ const rotateStyle = computed(() => ({
+ transformOrigin: 'center',
+ transform: `rotate(${watermark.value.rotate}deg)`,
+ }))
+
+ const imageUrl = computed(() => {
+ if (!enableWatermark)
+ return ''
+ const image = watermark.value.image || ''
+ const source = typeof image === 'string' ? image : image[isDark.value ? 'dark' : 'light']
+ return !source ? '' : isLinkHttp(source) ? source : withBase(source)
+ })
+
+ const svgElRef = ref()
+ const watermarkUrl = ref('')
+ const imageBase64 = ref('')
+ const defaultTextColor = ref('')
+
+ const content = computed(() => watermark.value.content)
+ const textColor = computed(() => {
+ if (!enableWatermark)
+ return ''
+ const textColor = watermark.value.textColor || defaultTextColor.value
+ return typeof textColor === 'string' ? textColor : textColor[isDark.value ? 'dark' : 'light']
+ })
+
+ 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) => {
+ // svg MIME type: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
+ const svgBlob = new Blob([svgStr], {
+ type: 'image/svg+xml',
+ })
+ return URL.createObjectURL(svgBlob)
+ }
+
+ const getDefaultTextColor = () => {
+ const color = typeof document !== 'undefined' && typeof window !== 'undefined'
+ ? window.getComputedStyle(document.documentElement).getPropertyValue('--vp-c-text-1')
+ : ''
+ defaultTextColor.value = color
+ }
+
+ watch(() => isDark.value, () => nextTick(getDefaultTextColor))
+
+ onMounted(getDefaultTextColor)
+
+ watchEffect(() => {
+ if (imageUrl.value && enableWatermark.value)
+ makeImageToBase64(imageUrl.value)
+ })
+
+ watch(
+ () => [
+ watermark.value,
+ imageBase64.value,
+ enableWatermark.value,
+ textColor.value,
+ ],
+ () => {
+ if (!enableWatermark.value)
+ return
+
+ 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 {
+ enableWatermark,
+ isFullPage,
+ imageUrl,
+ content,
+ textColor,
+ svgElRef,
+ svgRect,
+ rotateStyle,
+ imageBase64,
+ watermarkUrl,
+ onlyPrint,
+ }
+}
diff --git a/theme/src/shared/frontmatter.ts b/theme/src/shared/frontmatter.ts
index 68a88d86..5a4ff0ff 100644
--- a/theme/src/shared/frontmatter.ts
+++ b/theme/src/shared/frontmatter.ts
@@ -1,4 +1,4 @@
-import type { NavItemWithLink, PlumeThemeImage } from '.'
+import type { NavItemWithLink, PlumeThemeImage, WatermarkOptions } from '.'
/* =============================== Home begin ==================================== */
export interface PlumeThemeHomeFrontmatter extends Omit {
@@ -110,6 +110,7 @@ export interface PlumeThemePageFrontmatter {
backToTop?: boolean
externalLink?: boolean
readingTime?: boolean
+ watermark?: boolean | Omit
}
export interface PlumeThemePostFrontmatter extends PlumeThemePageFrontmatter {
diff --git a/theme/src/shared/options/locale.ts b/theme/src/shared/options/locale.ts
index 802de0f2..2541ded0 100644
--- a/theme/src/shared/options/locale.ts
+++ b/theme/src/shared/options/locale.ts
@@ -1,5 +1,6 @@
import type { LocaleData } from 'vuepress/core'
import type { NotesDataOptions } from '@vuepress-plume/plugin-notes-data'
+import type { PlumeThemeImage } from '../base.js'
import type { NavItem } from './navbar.js'
export interface PlumeThemeAvatar {
@@ -170,6 +171,73 @@ export interface LastUpdatedOptions {
formatOptions?: Intl.DateTimeFormatOptions & { forceLocale?: boolean }
}
+export interface WatermarkOptions {
+ /**
+ * 是否全局启用, 在不全局启用时,可以通过 `frontmatter.watermark` 为当前页面启用
+ * @default false
+ */
+ global?: boolean
+ /**
+ * 非全局启用时,可以通过该字段根据文件路径或页面链接 进行匹配, 来启用水印。
+ * 以 `^` 的将被认为为类似于正则表达式进行匹配。
+ */
+ matches?: string | string[]
+ /**
+ * 水印之间的水平间隔
+ * @default 0
+ */
+ gapX?: number
+ /**
+ * 水印之间的垂直间隔
+ * @default 0
+ */
+ gapY?: number
+
+ /**
+ * 图片水印的内容,如果与 content 同时传入,优先使用图片水印
+ */
+ image?: PlumeThemeImage
+ /**
+ * 水印宽度
+ * @default 100
+ */
+ width?: number
+ /**
+ * 水印高度
+ * @default 100
+ */
+ height?: number
+ /**
+ * 水印旋转角度
+ * @default -22
+ */
+ rotate?: number
+ /**
+ * 水印的内容,如果与 image 同时传入,优先使用图片水印
+ */
+ content?: string
+ /**
+ * 水印是否全屏显示
+ */
+ fullPage?: boolean
+ /**
+ * 水印透明度
+ * @default 0.1
+ */
+ opacity?: number
+
+ /**
+ * 水印的文本颜色
+ */
+ textColor?: string | { dark: string, light: string }
+
+ /**
+ * 是否只在打印时显示
+ * @default false
+ */
+ onlyPrint?: boolean
+}
+
export interface PlumeThemeLocaleData extends LocaleData {
/**
* 网站站点首页
@@ -376,6 +444,11 @@ export interface PlumeThemeLocaleData extends LocaleData {
linkLabel?: string
linkText?: string
}
+
+ /**
+ * 是否开启水印
+ */
+ watermark?: boolean | WatermarkOptions
/**
* 加密
*/