mirror of
https://github.com/sendevia/website.git
synced 2026-03-05 23:32:45 +08:00
feat: 实现动态取色功能
This commit is contained in:
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
vite: {
|
||||
define: {
|
||||
__SITE_VERSION__: JSON.stringify(packageJson.version || "0.0.0"),
|
||||
__DEFAULT_COLOR__: JSON.stringify("#39c5bb"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
198
.vitepress/theme/components/Header.vue
Normal file
198
.vitepress/theme/components/Header.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
const { frontmatter } = useGlobalData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="header">
|
||||
<svg width="0" height="0">
|
||||
<filter id="noise-filter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="1" numOctaves="5" :seed="Date.now()" result="noise" />
|
||||
<feColorMatrix type="saturate" values="0" result="desaturatedNoise" />
|
||||
<feComponentTransfer>
|
||||
<feFuncR type="discrete" tableValues="0 1" />
|
||||
<feFuncG type="discrete" tableValues="0 1" />
|
||||
<feFuncB type="discrete" tableValues="0 1" />
|
||||
<feFuncA type="discrete" tableValues="1 1" />
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
<div id="header-hero-container">
|
||||
<span id="header-hero-headline">{{ frontmatter.title }}</span>
|
||||
<span id="header-hero-subtitle">{{ frontmatter.description }}</span>
|
||||
<div id="header-impression">
|
||||
<div id="header-impression-noise"></div>
|
||||
<div
|
||||
id="header-impression-image"
|
||||
:style="{ backgroundImage: frontmatter.impression ? `url('${frontmatter.impression}')` : '' }"
|
||||
:impression-color="frontmatter.color"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "sass:meta";
|
||||
@use "../styles/mixin";
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
|
||||
height: 540px;
|
||||
|
||||
word-break: break-word;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#header-hero-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
|
||||
height: 100%;
|
||||
|
||||
padding: 54px;
|
||||
|
||||
color: var(--md-ref-palette-secondary100);
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-medium);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
|
||||
overflow: hidden;
|
||||
transition: var(--md-sys-motion-duration-extra-long4) var(--md-sys-motion-easing-standard);
|
||||
z-index: 1;
|
||||
|
||||
#header-hero-headline {
|
||||
@include mixin.typescale-style("display-large", $font-size: 90rem, $font-weight: 700, $line-height: 90rem);
|
||||
|
||||
width: 100%;
|
||||
|
||||
text-align: center;
|
||||
|
||||
mix-blend-mode: hard-light;
|
||||
transition: var(--md-sys-motion-duration-short1) var(--md-sys-motion-easing-standard);
|
||||
}
|
||||
|
||||
#header-hero-subtitle {
|
||||
@include mixin.typescale-style("headline-large", $font-size: 22rem, $line-height: 22rem);
|
||||
|
||||
width: 100%;
|
||||
|
||||
text-align: center;
|
||||
|
||||
mix-blend-mode: hard-light;
|
||||
transition: var(--md-sys-motion-duration-short1) var(--md-sys-motion-easing-standard);
|
||||
}
|
||||
|
||||
#header-impression {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
background-color: var(--md-ref-palette-secondary48);
|
||||
|
||||
transition: background-color var(--md-sys-motion-duration-extra-long4) var(--md-sys-motion-easing-standard);
|
||||
z-index: -1;
|
||||
|
||||
#header-impression-noise {
|
||||
position: relative;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
filter: url(#noise-filter);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.2;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#header-impression-image {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
background: center/cover no-repeat;
|
||||
|
||||
opacity: 0.8;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: var(--md-ref-palette-secondary10);
|
||||
|
||||
#header-impression-noise {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
#header-impression-image {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
height: 45vh;
|
||||
min-height: 360px;
|
||||
|
||||
#header-hero-container {
|
||||
grid-column: span 2;
|
||||
|
||||
padding: 5vw;
|
||||
|
||||
#header-hero-headline {
|
||||
@include mixin.typescale-style("display-large", $font-size: 7vw, $font-weight: 500, $line-height: 7vw);
|
||||
}
|
||||
|
||||
#header-hero-subtitle {
|
||||
@include mixin.typescale-style("display-small", $font-size: 18rem, $font-weight: 500, $line-height: 20rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
#header-hero-container {
|
||||
width: 100%;
|
||||
|
||||
#header-hero-headline {
|
||||
@include mixin.typescale-style("display-large", $font-size: 8vw, $font-weight: 600, $line-height: 8vw);
|
||||
}
|
||||
|
||||
#header-hero-subtitle {
|
||||
@include mixin.typescale-style("headline-large", $font-size: 23rem, $line-height: 23rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#header-hero-container {
|
||||
padding: 30px;
|
||||
|
||||
#header-hero-headline {
|
||||
@include mixin.typescale-style("display-large", $font-size: 40rem, $font-weight: 700, $line-height: 40rem);
|
||||
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#header-hero-subtitle {
|
||||
@include mixin.typescale-style("display-small", $font-size: 15rem, $font-weight: 400, $line-height: 17rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
import AllPostsLayout from "./AllPosts.vue";
|
||||
import ArticleLayout from "./Article.vue";
|
||||
import Footer from "../components/Footer.vue";
|
||||
import NotFoundLayout from "./NotFound.vue";
|
||||
import SearchPostsLayout from "./SearchPosts.vue";
|
||||
import Footer from "../components/Footer.vue";
|
||||
import Sidebar from "../components/Sidebar.vue";
|
||||
import { argbFromHex } from "@material/material-color-utilities";
|
||||
import { generateColorPalette } from "../utils/colorPalette";
|
||||
import { onMounted, nextTick } from "vue";
|
||||
import { useRoute } from "vitepress";
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
const { site, page, frontmatter } = useGlobalData();
|
||||
|
||||
/**
|
||||
* 获取图片主色调(取图片左上角像素点的颜色)
|
||||
* @param url 图片地址
|
||||
* @returns Promise<number | null> 返回 ARGB 颜色值,获取失败返回 null
|
||||
*/
|
||||
function getImageMainColor(url: string): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!url) return resolve(null);
|
||||
const img = new window.Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return resolve(null);
|
||||
ctx.drawImage(img, 0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
const argb = (255 << 24) | (r << 16) | (g << 8) | b;
|
||||
resolve(argb >>> 0);
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据头图动态更新调色板
|
||||
* 1. 等待 DOM 更新后查找 id="header-impression-image" 的元素
|
||||
* 2. 如果找不到该元素,则使用默认颜色生成调色板
|
||||
* 3. 如果元素有 impression-color 属性且为合法的 hex 颜色,则用该颜色生成调色板
|
||||
* 4. 否则尝试从元素的 backgroundImage 提取图片 URL,并获取图片主色调
|
||||
* 5. 最终调用 generateColorPalette 生成调色板
|
||||
*/
|
||||
async function updatePalette() {
|
||||
await nextTick();
|
||||
const el = document.getElementById("header-impression-image");
|
||||
// @ts-ignore
|
||||
const defaultColor = __DEFAULT_COLOR__;
|
||||
if (!el) {
|
||||
await generateColorPalette(argbFromHex(defaultColor));
|
||||
return;
|
||||
}
|
||||
|
||||
const colorAttr = el.getAttribute("impression-color");
|
||||
if (colorAttr && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(colorAttr)) {
|
||||
const argb = argbFromHex(colorAttr);
|
||||
await generateColorPalette(argb);
|
||||
return;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
const bg = style.backgroundImage;
|
||||
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
||||
const url = match?.[1] || null;
|
||||
let argb: number | null = null;
|
||||
if (url) {
|
||||
argb = await getImageMainColor(url);
|
||||
}
|
||||
await generateColorPalette(argb ?? argbFromHex(defaultColor));
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(updatePalette);
|
||||
|
||||
function onAfterEnter() {
|
||||
updatePalette();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use "mixin";
|
||||
|
||||
:root {
|
||||
// Default colors
|
||||
// Base colors
|
||||
--md-ref-palette-primary10: #410006;
|
||||
--md-ref-palette-primary20: #68000f;
|
||||
--md-ref-palette-primary30: #8c1520;
|
||||
|
||||
89
.vitepress/theme/utils/colorPalette.ts
Normal file
89
.vitepress/theme/utils/colorPalette.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { CorePalette, hexFromArgb, Blend, argbFromHex } from "@material/material-color-utilities";
|
||||
|
||||
/**
|
||||
* 调色板提供器
|
||||
* @param argbColor 颜色来源
|
||||
* @param name token的后缀
|
||||
* @param append 调色板的类型
|
||||
* @param tones 调色板的色相
|
||||
* @return CorePalette Object
|
||||
*/
|
||||
function paletteProperty(argbColor: number, name: string, append: "a1" | "a2" | "a3" | "n1" | "n2" | "error", tones: number[]) {
|
||||
const palette = CorePalette.of(argbColor);
|
||||
return {
|
||||
rawPalette: {
|
||||
[name]: palette[append],
|
||||
},
|
||||
tones: tones,
|
||||
};
|
||||
}
|
||||
|
||||
const globalPaletteTokens: Record<string, string> = {};
|
||||
let paletteUpdateTimer: number | null = null;
|
||||
|
||||
function flushPaletteTokens() {
|
||||
let styleElement = document.getElementById("md-ref-palette-style") as HTMLStyleElement | null;
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style");
|
||||
styleElement.id = "md-ref-palette-style";
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
styleElement.innerHTML = `:root { ${Object.entries(globalPaletteTokens)
|
||||
.map(([varName, color]) => `${varName}: ${color};`)
|
||||
.join(" ")} }`;
|
||||
}
|
||||
|
||||
function setPalette(paletteProvider: { rawPalette: Record<string, any>; tones: number[] }) {
|
||||
Object.entries(paletteProvider.rawPalette).forEach(([key, palette]) => {
|
||||
const paletteKey = key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
paletteProvider.tones.forEach((tone) => {
|
||||
const varName = `--md-ref-palette-${paletteKey}${tone}`;
|
||||
globalPaletteTokens[varName] = hexFromArgb(palette.tone(tone));
|
||||
});
|
||||
});
|
||||
|
||||
if (paletteUpdateTimer !== null) {
|
||||
clearTimeout(paletteUpdateTimer);
|
||||
}
|
||||
paletteUpdateTimer = window.setTimeout(() => {
|
||||
flushPaletteTokens();
|
||||
paletteUpdateTimer = null;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据颜色生成调色板
|
||||
* @param baseColor - 输入一个 argb 颜色
|
||||
*/
|
||||
export async function generateColorPalette(baseColor: number) {
|
||||
type PaletteAppend = "a1" | "a2" | "a3" | "n1" | "n2" | "error";
|
||||
type Palette = { name: string; append: PaletteAppend; tones: number[] };
|
||||
type HarmonizedPalette = { color: string; name: string; append: PaletteAppend; tones: number[] };
|
||||
|
||||
const palettes: Palette[] = [
|
||||
{ name: "error", append: "error", tones: [10, 20, 30, 40, 80, 90, 100] },
|
||||
{ name: "neutralVariant", append: "n2", tones: [30, 50, 60, 80, 90] },
|
||||
{ name: "neutral", append: "n1", tones: [0, 4, 6, 10, 12, 17, 20, 22, 24, 87, 90, 92, 94, 95, 96, 98, 100] },
|
||||
{ name: "tertiary", append: "a3", tones: [10, 20, 30, 40, 80, 90, 100] },
|
||||
{ name: "secondary", append: "a2", tones: [10, 20, 30, 40, 48, 80, 90, 100] },
|
||||
{ name: "primary", append: "a1", tones: [10, 20, 30, 40, 80, 90, 100] },
|
||||
];
|
||||
|
||||
const harmonizedPalettes: HarmonizedPalette[] = [
|
||||
{ color: "#c08eaf", name: "purple", append: "a1", tones: [10, 20, 30, 40, 80, 90, 95] },
|
||||
{ color: "#f9d770", name: "yellow", append: "a1", tones: [10, 20, 30, 40, 80, 90, 95] },
|
||||
{ color: "#68b88e", name: "green", append: "a1", tones: [10, 20, 30, 40, 80, 90, 95] },
|
||||
{ color: "#5cb3cc", name: "blue", append: "a1", tones: [10, 20, 30, 40, 80, 90, 95] },
|
||||
{ color: "#c27c88", name: "red", append: "a1", tones: [10, 20, 30, 40, 80, 90, 95] },
|
||||
];
|
||||
|
||||
for (const palette of palettes) {
|
||||
const paletteObject = paletteProperty(baseColor, palette.name, palette.append, palette.tones);
|
||||
setPalette(paletteObject);
|
||||
}
|
||||
|
||||
for (const palette of harmonizedPalettes) {
|
||||
const paletteObject = paletteProperty(Blend.harmonize(argbFromHex(palette.color), baseColor), palette.name, palette.append, palette.tones);
|
||||
setPalette(paletteObject);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,8 @@
|
||||
"devDependencies": {
|
||||
"sass-embedded": "^1.93.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user