Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .vitepress/config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineConfig } from 'vitepress'
import { generateNavAndSidebar } from './navSidebar.mjs'
import { quantityMarkdownPlugin } from './quantity-markdown.mjs'

const { nav, sidebar } = generateNavAndSidebar(process.cwd())

Expand All @@ -12,6 +13,11 @@ export default defineConfig({
base: '/CookLikeHOC/',
ignoreDeadLinks: true,
srcExclude: ['**/README.md'],
markdown: {
config: (md) => {
md.use(quantityMarkdownPlugin)
},
},
themeConfig: {
logo: '/logo.png',
nav: [
Expand Down
7 changes: 7 additions & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfig } from 'vitepress'
import { generateNavAndSidebar } from './navSidebar'
// @ts-ignore - .mjs 无类型声明,构建由 esbuild 处理
import { quantityMarkdownPlugin } from './quantity-markdown.mjs'

const { nav, sidebar } = generateNavAndSidebar(process.cwd())

Expand All @@ -9,6 +11,11 @@ export default defineConfig({
description: '像老乡鸡那样做饭',
lastUpdated: true,
cleanUrls: true,
markdown: {
config: (md) => {
md.use(quantityMarkdownPlugin)
},
},
themeConfig: {
logo: '/logo.png',
nav: [
Expand Down
61 changes: 61 additions & 0 deletions .vitepress/quantity-markdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// markdown-it 插件:把菜谱正文中的「数字 + 单位」包裹成
// <span class="qty" data-qty="…" data-unit="…">…</span>,供前端按比例缩放。
// 不修改任何源 .md 文件。

// 可缩放的重量/体积单位(国家标准以 g 为主)与计数单位。
// 注意:长度(cm)、热量(Kcal)、营养(mg)、包装单位(包/袋/盒/瓶)、时间温度等不缩放。
const SCALE_UNITS = [
'千克', '毫克', '毫升',
'kg', 'mg', 'ml',
'克', '升', '斤', '两',
'个', '只', '片', '根', '块', '瓣', '朵', '条', '份',
'颗', '粒', '段', '节', '勺', '匙', '滴', '杯', '碗', '把',
'g', 'l', 'L',
].join('|')

// 匹配「数字 + (可选空白) + 单位」,且单位后不能紧跟英文字母(避免误匹配英文单词)。
const QUANTITY_RE_SOURCE = `(\\d+(?:\\.\\d+)?)(\\s*)(${SCALE_UNITS})(?![a-zA-Z])`

function renderWrapped(content, escapeHtml) {
const re = new RegExp(QUANTITY_RE_SOURCE, 'g')
let out = ''
let last = 0
let matched = false
let m
while ((m = re.exec(content)) !== null) {
matched = true
out += escapeHtml(content.slice(last, m.index))
const num = m[1]
const gap = m[2]
const unit = m[3]
out += `<span class="qty" data-qty="${num}" data-unit="${escapeHtml(gap + unit)}">${escapeHtml(num + gap + unit)}</span>`
last = m.index + m[0].length
}
if (!matched) return null
out += escapeHtml(content.slice(last))
return out
}

export function quantityMarkdownPlugin(md) {
const escapeHtml = md.utils.escapeHtml

md.core.ruler.push('scale-quantity', (state) => {
let inTable = false
for (const token of state.tokens) {
if (token.type === 'table_open') {
inTable = true
} else if (token.type === 'table_close') {
inTable = false
} else if (token.type === 'inline' && !inTable && token.children) {
for (const child of token.children) {
if (child.type !== 'text') continue
const html = renderWrapped(child.content, escapeHtml)
if (html === null) continue
child.type = 'html_inline'
child.content = html
}
}
}
return true
})
}
11 changes: 11 additions & 0 deletions .vitepress/theme/Layout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
import DefaultTheme from 'vitepress/theme'
import QuantityScaler from './components/QuantityScaler.vue'

const { Layout } = DefaultTheme
</script>

<template>
<Layout />
<QuantityScaler />
</template>
174 changes: 174 additions & 0 deletions .vitepress/theme/components/QuantityScaler.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

const MIN = 0.1
const MAX = 2
const STEP = 0.05

const PRESETS = [
{ label: '¼份', value: 0.25 },
{ label: '½份', value: 0.5 },
{ label: '1份', value: 1 },
{ label: '2份', value: 2 },
]

const scale = ref(1)
const hasQuantities = ref(false)
let observer: MutationObserver | null = null

const scaleLabel = computed(() => String(Math.round(scale.value * 100) / 100))

function formatQty(qty: number, s: number): string {
return String(Math.round(qty * s * 100) / 100)
}

function applyScale() {
const els = document.querySelectorAll<HTMLElement>('.vp-doc .qty')
hasQuantities.value = els.length > 0
for (const el of els) {
const qty = Number.parseFloat(el.dataset.qty ?? '0')
const unit = el.dataset.unit ?? ''
const next = formatQty(qty, scale.value) + unit
// 幂等:仅在需要时改写,避免 MutationObserver 反复触发
if (el.textContent !== next) el.textContent = next
}
}

function onSlider(e: Event) {
scale.value = Number((e.target as HTMLInputElement).value)
applyScale()
}

function setPreset(v: number) {
scale.value = v
applyScale()
}

function reset() {
scale.value = 1
applyScale()
}

onMounted(() => {
applyScale()
// 客户端路由切换时 .vp-doc 内容会被替换,监听 DOM 变化并重新应用比例
observer = new MutationObserver(() => applyScale())
observer.observe(document.body, { childList: true, subtree: true })
})

onBeforeUnmount(() => {
observer?.disconnect()
})
</script>

<template>
<div v-if="hasQuantities" class="qty-scaler">
<div class="qty-scaler__header">
<span class="qty-scaler__title">份量</span>
<span class="qty-scaler__value">×{{ scaleLabel }}</span>
<button class="qty-scaler__reset" type="button" @click="reset">重置</button>
</div>
<input
class="qty-scaler__slider"
type="range"
:min="MIN"
:max="MAX"
:step="STEP"
:value="scale"
aria-label="调整份量比例"
@input="onSlider"
/>
<div class="qty-scaler__presets">
<button
v-for="p in PRESETS"
:key="p.value"
type="button"
:class="{ 'is-active': scale === p.value }"
@click="setPreset(p.value)"
>
{{ p.label }}
</button>
</div>
</div>
</template>

<style scoped>
.qty-scaler {
position: fixed;
left: 1rem;
bottom: 1rem;
z-index: 30;
width: 264px;
padding: 0.75rem 1rem;
border-radius: 12px;
background: var(--vp-c-bg-elv, var(--vp-c-bg-soft, #fff));
border: 1px solid var(--vp-c-divider, rgba(60, 60, 60, 0.12));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
font-size: 0.875rem;
}

.qty-scaler__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}

.qty-scaler__title {
font-weight: 600;
}

.qty-scaler__value {
font-weight: 700;
color: var(--vp-c-brand-1, #3451b2);
}

.qty-scaler__reset {
margin-left: auto;
border: 1px solid var(--vp-c-divider, rgba(60, 60, 60, 0.12));
background: transparent;
color: var(--vp-c-text-1, inherit);
border-radius: 6px;
padding: 0.1rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
}

.qty-scaler__slider {
width: 100%;
accent-color: var(--vp-c-brand-1, #3451b2);
}

.qty-scaler__presets {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}

.qty-scaler__presets button {
flex: 1;
border: 1px solid var(--vp-c-divider, rgba(60, 60, 60, 0.12));
background: transparent;
color: var(--vp-c-text-1, inherit);
border-radius: 6px;
padding: 0.2rem 0;
cursor: pointer;
font-size: 0.75rem;
}

.qty-scaler__presets button.is-active {
border-color: var(--vp-c-brand-1, #3451b2);
color: var(--vp-c-brand-1, #3451b2);
font-weight: 600;
}

@media (max-width: 640px) {
.qty-scaler {
left: 0;
right: 0;
bottom: 0;
width: auto;
border-radius: 12px 12px 0 0;
}
}
</style>
2 changes: 2 additions & 0 deletions .vitepress/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import DefaultTheme from 'vitepress/theme'
import Layout from './Layout.vue'
import './style.css'

export default {
extends: DefaultTheme,
Layout,
}