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 /** * 加密 */