1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-05 23:32:45 +08:00

feat: 实现动态取色功能

This commit is contained in:
2025-09-28 18:23:49 +08:00
parent 538d3bb909
commit 96ff2a496f
6 changed files with 368 additions and 3 deletions

View File

@@ -29,6 +29,7 @@ export default defineConfig({
vite: {
define: {
__SITE_VERSION__: JSON.stringify(packageJson.version || "0.0.0"),
__DEFAULT_COLOR__: JSON.stringify("#39c5bb"),
},
},
});

View 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>

View File

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

View File

@@ -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;

View 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);
}
}

View File

@@ -8,5 +8,8 @@
"devDependencies": {
"sass-embedded": "^1.93.0",
"vue": "^3.5.0"
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
}
}