feat(theme): 新增 文章水印 功能

This commit is contained in:
pengzhanbo 2024-04-14 19:28:45 +08:00
parent c6a2b8cab2
commit 80b3da3e58
5 changed files with 376 additions and 3 deletions

View File

@ -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())
<PageMeta />
<EncryptPage v-if="!isPageDecrypted" />
<template v-else>
<Content class="plume-content" />
<div style="position: relative;">
<Content class="plume-content" />
<Watermark />
</div>
<PageFooter />
<PageComment v-if="hasComments" :darkmode="isDark" />
</template>

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
import { useWaterMark } from '../composables/index.js'
const {
enableWatermark,
isFullPage,
svgElRef,
svgRect,
imageUrl,
content,
textColor,
rotateStyle,
imageBase64,
watermarkUrl,
onlyPrint,
} = useWaterMark()
</script>
<template>
<div
v-if="enableWatermark"
class="watermark-wrapper"
:class="{ full: isFullPage, print: onlyPrint }"
:style="{ backgroundImage: `url(${watermarkUrl})` }"
>
<div ref="svgElRef" class="watermark">
<svg
:viewBox="`0 0 ${svgRect.svgWidth} ${svgRect.svgHeight}`"
:width="svgRect.svgWidth"
:height="svgRect.svgHeight"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:style="{
padding: `0 ${svgRect.gapX}px ${svgRect.gapY}px 0`,
opacity: svgRect.opacity,
}"
>
<image
v-if="imageUrl"
:href="imageBase64"
:xlink:href="imageBase64"
x="0"
y="0"
:width="svgRect.width"
:height="svgRect.height"
:style="rotateStyle"
/>
<foreignObject
v-else
x="0"
y="0"
:width="svgRect.width"
:height="svgRect.height"
>
<div
xmlns="http://www.w3.org/1999/xhtml"
:style="rotateStyle"
>
<p class="watermark-content" :style="{ color: textColor }" v-html="content" />
</div>
</foreignObject>
</svg>
</div>
</div>
</template>
<style scoped>
.watermark-wrapper {
position: absolute;
top: 0;
left: 0;
z-index: 19;
width: 100%;
height: 100%;
pointer-events: none;
background-color: transparent;
background-repeat: repeat;
}
.watermark-wrapper .watermark {
display: none;
}
.watermark-wrapper.full {
position: fixed;
top: var(--vp-nav-height);
z-index: 9999;
}
.watermark-wrapper .watermark-content {
display: inline-block;
margin: 0;
}
.watermark-wrapper.print {
display: none;
}
@media print {
.watermark-wrapper.print {
display: block;
}
}
</style>

View File

@ -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<PlumeThemePageData>()
const frontmatter = usePageFrontmatter<PlumeThemePageFrontmatter>()
const route = useRoute()
const watermark = computed<WatermarkOptions>(() => {
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<HTMLDivElement>()
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,
}
}

View File

@ -1,4 +1,4 @@
import type { NavItemWithLink, PlumeThemeImage } from '.'
import type { NavItemWithLink, PlumeThemeImage, WatermarkOptions } from '.'
/* =============================== Home begin ==================================== */
export interface PlumeThemeHomeFrontmatter extends Omit<PlumeThemeHomeBanner, 'type'> {
@ -110,6 +110,7 @@ export interface PlumeThemePageFrontmatter {
backToTop?: boolean
externalLink?: boolean
readingTime?: boolean
watermark?: boolean | Omit<WatermarkOptions, 'global' | 'matches'>
}
export interface PlumeThemePostFrontmatter extends PlumeThemePageFrontmatter {

View File

@ -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
/**
*
*/