Compare commits

...

8 Commits

Author SHA1 Message Date
xfy
302e05fe1f fix(docker): 补齐 Dockerfile 缺失的 katex-css 与 restore-webp 步骤
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
对齐 make build-linux 流水线,修两个同步遗漏:

- katex-css:镜像缺此步导致 $...$/$$...$$ 渲染成裸 span,无 KaTeX
  字体排版。
- restore-webp:dx 0.7.9 会把 public/*.webp 重编码成 VP8L 静图(动画帧
  丢失、体积反增 7-8×),需用源文件覆盖回 dx 产物目录后再拷贝。

两处均在注释中标注 keep in sync with make build-linux。
2026-07-16 18:16:35 +08:00
xfy
71211bc68d chore(docker): GitHub Releases 下载走 gh-proxy 镜像
dx tarball 与 tailwindcss 二进制都在 github.com releases 上,国内下载
~300KB/s 且频繁连接重置。新增 ARG GH_PROXY (默认 https://gh-proxy.com)
前缀到两处 GitHub 下载 URL;传空值可回退直连,便于非大陆环境构建。
2026-07-16 18:03:09 +08:00
xfy
ae1efda462 fix(build): build 前清除 static/ SSR 缓存目录
避免增量构建时残留的 SSR 缓存导致页面内容不更新。
与 make clean 中已有的 static/ 清理保持一致。
2026-07-16 17:39:44 +08:00
xfy
aac0c8198d fix(docker): 升级 builder 至 trixie 以满足 dx 对 GLIBC_2.39 的需求
dx v0.7.9 预编译二进制(both aarch64/x86_64-unknown-linux-gnu)依赖
GLIBC_2.39,而 Debian Bookworm (glibc 2.36) 不满足该要求。
将 builder 阶段基础镜像从 rust:1.96-bookworm 升级至
rust:1.96-trixie (Debian 13, glibc 2.41)。
2026-07-16 17:39:41 +08:00
xfy
0395c55213 fix(mermaid): 修复 tsc 类型错误
- mermaid.ts: initMermaid 早退路径(root 未命中)返回 Promise.resolve() 而非
  undefined,匹配 Promise<void> 签名。
- theme-transition.test.ts: resolveAsync 改用 mutable 容器 { fn: null } 包裹,
  绕过 TS 跨闭包控制流收窄(原 let + 闭包赋值,在 ?.() 处被判定为 null → never)。

上提交漏跑 tsc --noEmit(vitest 用 esbuild 不做类型检查),本次补验 tsc + vite build。
2026-07-16 17:06:06 +08:00
xfy
bdfc7a19e9 feat(mermaid): 流程图主题切换跟随 VT 圆形扩散动画
此前 mermaid.render() 异步,而 VT 回调同步,Dioxus effect 重跑发生在 VT 回调
之后的微任务,导致动画期间流程图不变、动画结束后才瞬切。

利用 startViewTransition 回调可返回 Promise 的特性(浏览器等其 resolve 才拍
NEW 快照、播动画),让 VT 回调等 mermaid 重渲染完成,新主题流程图进入快照,
圆形扩散覆盖整页含 mermaid 区域。

改动:
- theme-transition.ts: 新增 onThemeChange registry(回调可返回 Promise)。
  notifyThemeChange 遍历 registry 收集 Promise 返回聚合结果;THEME_CHANGE_EVENT
  事件保留(给 codemirror/xterm 同步换肤,向后兼容)。
  startThemeTransition 的 VT 回调改为 async,await notifyThemeChange 聚合 Promise,
  确保异步换肤组件(mermaid)完成后再拍 NEW 快照。
  applyResolvedTheme(跟随系统瞬切)不等 registry,保持瞬切语义。
- mermaid.ts: 顶层注册 onThemeChange 订阅,listener 调 rerenderExistingBlocks 并
  返回其 Promise,供 VT 回调等待。initMermaid/rerenderExistingBlocks 返回 Promise。
- post_content.rs: VT 期间(is-theme-transitioning)跳过 __initMermaid 调用,避免
  Dioxus effect 抢先重渲染穿透伪元素快照(照搬 code_runner/runner.rs 守卫);
  动画结束后 effect 重跑做幂等兜底。
- theme-transition.test.ts: VT callback 签名 void→Promise<void>,新增 registry
  等待测试 + 降级路径不等 registry 测试。
2026-07-16 16:58:27 +08:00
xfy
6eb7a743e5 fix(mermaid): 主题切换时重渲染已渲染的流程图
mermaid 把颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题。此前 initMermaid
的幂等守卫一刀切跳过所有已渲染块,导致切主题后流程图停留在首次渲染的主题。

改动:
- initMermaid 改为双路径分发:未渲染块(<code>还在)走 IntersectionObserver
  懒加载;已渲染块(pre 内容已是 SVG)按 dataset.mermaidSource 缓存源码重渲染,
  同主题幂等跳过,主题变化才重渲染。
- 渲染逻辑抽到 renderBlock:用全局自增 renderCounter 生成唯一 id,避开 mermaid
  残留的 d-前缀布局节点冲突(#357)与同页 marker id 冲突(#5741);替换前清空
  pre.innerHTML 确保亮/暗两版 SVG 不并存。
- source 存进 dataset.mermaidSource 供重渲染回取(此时 <code> 已被 SVG 替换)。

触发机制不变:post_content.rs 的 use_effect 读 use_resolved_theme() 建订阅,
主题切换时重跑重调 __initMermaid 传入新 theme;mermaid.ts 据此决定是否重渲染。
2026-07-16 16:42:04 +08:00
xfy
f7a3336b76 style(details): 正文折叠块卡片化,自绘 chevron + hover/focus 态
正文手写的 <details>(区别于 .toc 导航块)此前只有浏览器默认样式
(灰三角 + 裸文字),与文章 Catppuccin 设计语言断档。

复用代码块/blockquote 的视觉语言:同款底色 + 边框 + 16px 圆角;
自绘 chevron 替代原生三角,展开旋转 90° 平滑过渡;
补 hover 浅背景与 :focus-visible 轮廓(WCAG 2.4.7);展开内容补
左右内边距与首尾 margin。

作用域 .md-content details:not(.toc),与 TOC 专用样式互不干扰。
2026-07-16 16:31:00 +08:00
9 changed files with 414 additions and 57 deletions

View File

@ -3,7 +3,10 @@
# -----------------------------------------------------------------------------
# Builder stage: compile the static-linked musl server binary and frontend assets
# -----------------------------------------------------------------------------
FROM rust:1.96-bookworm AS builder
# Trixie (Debian 13, glibc 2.41) — required because the prebuilt `dx` v0.7.9
# binary (aarch64/x86_64-unknown-linux-gnu) needs GLIBC_2.39; Bookworm only
# ships 2.36, so `dx --version` fails with "version `GLIBC_2.39' not found".
FROM rust:1.96-trixie AS builder
# Point every network download at a Chinese mirror so the build is fast/reliable
# from inside the container (the host proxy at 127.0.0.1:10808 is unreachable
@ -16,6 +19,12 @@ ARG DEBIAN_SECURITY_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG NODE_MIRROR=https://registry.npmmirror.com/-/binary/node
ARG NPM_REGISTRY=https://registry.npmmirror.com
ARG RS_PROXY=https://rsproxy.cn
# GitHub Releases proxy — the dx tarball and tailwindcss binary live on
# github.com releases and download at ~300 KB/s with frequent connection
# resets from China. Prefixing the raw github.com URL routes the download
# through the proxy. Set to "" (empty) to bypass the proxy (e.g. building
# outside China where github.com is fast/reliable).
ARG GH_PROXY=https://gh-proxy.com
# --- Debian apt: rewrite the DEB822 sources to the TUNA mirror. ---
RUN sed -i \
@ -79,20 +88,29 @@ RUN rustup target add wasm32-unknown-unknown \
# Install the Dioxus CLI from the official prebuilt binary (GitHub Releases),
# NOT `cargo install` (which compiles dx-cli's huge dep tree from source — the
# slowest single Docker step). The release tag v0.7.9 matches the crate version
# we previously pinned. dx runs only in this builder stage (to emit the WASM
# client bundle); it never enters the static-musl runtime image, so the glibc
# (linux-gnu) linking of the prebuilt binary is fine here. Each buildx platform
# leg downloads only its native arch; the sha256 pins the exact artifact
# (supply-chain integrity, verified against the release's .sha256 sidecar).
# we previously pinned. The prebuilt dx is a glibc (linux-gnu) binary requiring
# GLIBC_2.39 — that's why the builder stage above uses Trixie (glibc 2.41), not
# Bookworm (glibc 2.36). dx runs only in this builder stage (to emit the WASM
# client bundle); it never enters the static-musl runtime image. Each buildx
# platform leg downloads only its native arch; the sha256 pins the exact
# artifact (supply-chain integrity, verified against the release's .sha256
# sidecar).
ARG DX_VERSION=0.7.9
# The 32 MB dx tarball sits on github.com releases; from China it downloads at
# ~300 KB/s and the connection is frequently reset mid-transfer with
# "curl: (56) ... unexpected eof while reading" — the same flaky-upstream
# problem the mirror rewrites above solve for apt/crates/npm. --retry with
# --retry-all-errors (curl 7.71+, Trixie ships 8.x) covers SSL/EOF resets, and
# --continue-at - resumes the partial file instead of restarting from zero on
# each retry. The sha256 pin still catches a corrupted/partial download.
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) DX_TRIPLET=x86_64-unknown-linux-gnu DX_SHA256=3b132551b480bc96f938f9f0d37936ee1190f994977539dcc347eaf38540d005 ;; \
arm64) DX_TRIPLET=aarch64-unknown-linux-gnu DX_SHA256=8cf14db0b11b43b31dd6d39e71b00e567f2fccfde85ae3a8f7ef0f8745e5ccfb ;; \
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
esac \
&& DX_URL="https://github.com/DioxusLabs/dioxus/releases/download/v${DX_VERSION}/dx-${DX_TRIPLET}.tar.gz" \
&& curl -fsSL "${DX_URL}" -o /tmp/dx.tar.gz \
&& DX_URL="${GH_PROXY:+${GH_PROXY}/}https://github.com/DioxusLabs/dioxus/releases/download/v${DX_VERSION}/dx-${DX_TRIPLET}.tar.gz" \
&& curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors --retry-connrefused --continue-at - "${DX_URL}" -o /tmp/dx.tar.gz \
&& echo "${DX_SHA256} /tmp/dx.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/dx.tar.gz -C /usr/local/bin \
&& rm /tmp/dx.tar.gz \
@ -107,7 +125,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
arm64) TW_ARCH=arm64 ;; \
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
esac \
&& GH_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \
&& GH_URL="${GH_PROXY:+${GH_PROXY}/}https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \
&& curl -fsSL -o /usr/local/bin/tailwindcss "${GH_URL}" \
&& chmod +x /usr/local/bin/tailwindcss
@ -126,15 +144,20 @@ RUN cd libs && pnpm install --frozen-lockfile
# Copy the rest of the source tree and build everything.
COPY . .
# Build all 4 JS libs, syntax-highlight CSS and Tailwind stylesheet.
# These steps produce the contents of the public/ directory.
RUN make build-libs && make highlight-css && tailwindcss -i input.css -o public/style.css --minify
# Build all 4 JS libs, syntax-highlight CSS, KaTeX CSS + fonts and Tailwind
# stylesheet. These steps produce the contents of the public/ directory.
# Must stay in sync with make build-linux — katex-css was previously missing,
# which left math rendering as bare spans without KaTeX fonts.
RUN make build-libs && make highlight-css && make katex-css && tailwindcss -i input.css -o public/style.css --minify
# Build the client-side Dioxus WASM bundle. We use dx only for the client assets;
# dx's linker wrapper is incompatible with a raw static linker, so the server
# binary is built with plain cargo in the next step. The client build emits a
# ready-to-serve public/ directory under target/dx/yggdrasil/*/web/public.
# restore-webp overwrites dx's re-encoded VP8L .webp stills with the source
# originals — keep in sync with make build-linux, which runs the same target.
RUN dx build @client --release --debug-symbols=false --wasm-js-cfg false && \
make restore-webp && \
mkdir -p /build/dist/public && \
cp -r /build/target/dx/yggdrasil/*/web/public/* /build/dist/public/

View File

@ -1,6 +1,7 @@
.PHONY: dev build build-linux build-freebsd freebsd-sysroot docker docker-amd64 docker-apple docker-multiarch css css-watch clean build-libs build-editor build-codemirror build-lightbox build-core build-xterm highlight-css katex-css test doc doc-open start lint fix restore-webp
build:
@rm -rf static/
@cd libs && pnpm install --frozen-lockfile
@$(MAKE) build-libs
@$(MAKE) highlight-css

View File

@ -274,6 +274,82 @@
opacity: 1;
}
/* 正文折叠块作者手写的 <details>区别于 .toc 导航块
复用代码块/blockquote 的视觉语言同款底色 + 边框 + 16px 圆角
自绘 chevron 替代浏览器原生三角展开时旋转 90°
:not(.toc) 避免与上面的 TOC 专用样式冲突 */
.md-content details:not(.toc) {
margin: 1rem 0;
background: var(--color-paper-code-bg);
border: 1px solid var(--color-paper-border);
border-radius: var(--radius-paper);
}
.md-content details:not(.toc) > summary {
padding: 0.6rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
color: var(--color-paper-primary);
font-weight: 500;
display: flex;
align-items: center;
gap: 0.4rem;
transition: background-color 120ms ease;
}
/* 去掉 WebKit/Blink 原生三角,统一用 ::before 自绘 chevron。 */
.md-content details:not(.toc) > summary::-webkit-details-marker {
display: none;
}
/* 自绘 chevron实心三角展开时旋转 90° 平滑过渡
currentColor 继承 summary 文字色随主题变化 */
.md-content details:not(.toc) > summary::before {
content: "";
display: inline-block;
flex-shrink: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0.32rem 0.42rem 0.32rem 0;
border-color: transparent currentColor transparent transparent;
transform: rotate(0deg);
transition: transform 150ms ease;
margin-inline-start: 0.1rem;
}
.md-content details:not(.toc)[open] > summary::before {
transform: rotate(90deg);
}
.md-content details:not(.toc) > summary:hover {
background-color: color-mix(in srgb, var(--color-paper-accent) 8%, transparent);
}
/* 键盘可访问focus-visible 给可见轮廓WCAG 2.4.7
.dark 前缀走暗色主题色 */
.md-content details:not(.toc) > summary:focus-visible {
outline: 2px solid var(--color-paper-accent);
outline-offset: -2px;
border-radius: var(--radius-paper);
}
/* 展开内容左右内边距(代码块/段落不贴边),末元素留底部呼吸空间。 */
.md-content details:not(.toc)[open] > *:not(summary) {
padding-inline: 1rem;
}
/* 首元素紧接 summary 去顶部 margin末元素去底部 margin容器已留白。 */
.md-content details:not(.toc)[open] > *:not(summary):first-of-type {
margin-top: 0;
}
.md-content details:not(.toc)[open] > *:not(summary):last-child {
margin-bottom: 0;
padding-bottom: 0.8rem;
}
.entry-cover {
margin-bottom: var(--content-gap-paper);
}

View File

@ -1,15 +1,15 @@
import type { ThemeName } from '@yggdrasil/shared';
import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll';
import { initMermaid } from './mermaid';
import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
import type { ThemeName } from '@yggdrasil/shared';
import './style.css';
declare global {
interface Window {
__initPostContent: (selector: string) => void;
__initMermaid: (selector: string, theme: ThemeName) => void;
__initMermaid: (selector: string, theme: ThemeName) => Promise<void>;
__initAnchorClick: () => void;
__scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void;

View File

@ -117,4 +117,67 @@ describe('initMermaid', () => {
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
});
});
it('主题切换时重渲染已渲染的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
// 首次渲染light
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('light');
const firstRenderCalls = mockRender.mock.calls.length;
// 主题切换 → dark应触发重渲染
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThan(firstRenderCalls);
});
expect(mockInitialize).toHaveBeenLastCalledWith(expect.objectContaining({ theme: 'dark' }));
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('dark');
});
it('主题未变时重渲染路径幂等(同主题跳过)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 同主题再调模拟上下篇切换复用组件实例、effect 重跑)
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('主题切换重渲染用唯一 render id避免 mermaid 残留节点冲突)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
const firstId = mockRender.mock.calls[0][0];
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThanOrEqual(2);
});
const secondId = mockRender.mock.calls[mockRender.mock.calls.length - 1][0];
// 两次 render 的 id 必须不同,否则撞上 mermaid 内部残留的 d-前缀节点(#357
expect(secondId).not.toBe(firstId);
});
});

View File

@ -12,9 +12,18 @@
* post-content.tsquerySelectorAll + + DOM
* mermaid SSR markdown.rs
* `language-mermaid` class data
*
* mermaid / SVG CSS
* SVG render SVGpost_content.rs
* use_effect use_resolved_theme() __initMermaid
* theme dataset.mermaidTheme
*/
import type { ThemeName } from '@yggdrasil/shared';
import { onThemeChange } from './theme-transition';
/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */
const POST_CONTENT_SELECTOR = '.post-content';
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
@ -29,6 +38,9 @@ declare global {
let mermaidPromise: Promise<MermaidApi> | null = null;
/** render id 自增计数器,保证每次 render 生成唯一 id避开 mermaid 残留节点冲突。 */
let renderCounter = 0;
/**
* mermaid IIFE bundle 便
*
@ -75,6 +87,33 @@ function loadMermaid(): Promise<MermaidApi> {
return mermaidPromise;
}
/**
* mermaid SVG <pre>
*
* - render id`mermaid-svg-${++renderCounter}` render
* mermaid `d`-mermaid#357 marker id
* mermaid#5741
* - pre.innerHTML SVG `d-` SVG
* /
* - source dataset.mermaidSource <code>
* SVG textContent
*/
async function renderBlock(pre: HTMLPreElement, source: string, theme: ThemeName): Promise<void> {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const id = `mermaid-svg-${++renderCounter}`;
const { svg } = await mermaid.render(id, source);
// 清空 pre 旧内容(旧 SVG + mermaid 残留的 `d-` 辅助节点),再注入新 SVG。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
pre.dataset.mermaidSource = source;
pre.dataset.mermaidTheme = theme;
}
/**
* mermaid <pre> IntersectionObserver
*
@ -101,33 +140,32 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
/**
* mermaid
*
* <code> SVG pre
* 1. `<code class="language-mermaid">` pre +
* IntersectionObserver
* 2. pre SVGdataset.mermaid-rendered
* post_content.rs effect
* bundle IntersectionObserver
*
* @param selector '.post-content'
* @param theme mermaid
*/
export function initMermaid(selector: string, theme: ThemeName): void {
export function initMermaid(selector: string, theme: ThemeName): Promise<void> {
const root = document.querySelector(selector);
if (!root) return;
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
if (blocks.length === 0) return;
if (!root) return Promise.resolve();
blocks.forEach((code, i) => {
// 路径 1未渲染的块<code> 还在)。
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
blocks.forEach((code) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
if (pre.dataset.mermaidRendered) return; // 理论不命中renderBlock 同步替换 innerHTML防御
const source = code.textContent || '';
pre.dataset.mermaidSource = source;
observeBlock(pre, async () => {
try {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const { svg } = await mermaid.render(`mermaid-svg-${i}`, source);
// 替换整个 <pre> 内容为 SVG丢弃 copy 按钮(图不需要复制源码)。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
await renderBlock(pre, source, theme);
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
@ -136,4 +174,52 @@ export function initMermaid(selector: string, theme: ThemeName): void {
}
});
});
// 路径 2已渲染的块<code> 已被 SVG 替换,按 dataset 回找)。无条件执行,
// 覆盖「页面上未渲染块与已渲染块并存」的场景。主题切换时由 onThemeChange 订阅
// 返回其 Promise供 VT callback await让新主题流程图进入 NEW 快照)。
return rerenderExistingBlocks(root, theme);
}
/**
* pre SVG <code>
*
* initMermaid pre <code> SVG
* `pre > code.language-mermaid` dataset
*
* Promise resolveonThemeChange
* VT callback await mermaid.render NEW
* catch + mermaid-error class
*/
function rerenderExistingBlocks(root: Element, theme: ThemeName): Promise<void> {
const rendered = root.querySelectorAll<HTMLPreElement>('pre[data-mermaid-rendered]');
const tasks: Promise<void>[] = [];
rendered.forEach((pre) => {
if (pre.dataset.mermaidTheme === theme) return;
const source = pre.dataset.mermaidSource;
if (!source) return; // 无缓存源码无法重渲染,保守跳过
tasks.push(
renderBlock(pre, source, theme).catch((err) => {
console.error('mermaid re-render failed:', err);
pre.classList.add('mermaid-error');
}),
);
});
return Promise.all(tasks).then(() => {});
}
/**
* 订阅主题切换:主题变化时重渲染已渲染的 mermaid , Promise
*
* VT 协调的关键: listener Promise notifyThemeChange VT callback
* await mermaid.render NEW
* VT( / ),mermaid
*
* (IIFE ),,
* rerenderExistingBlocks no-op
*/
onThemeChange((isDark) => {
const root = document.querySelector(POST_CONTENT_SELECTOR);
if (!root) return;
return rerenderExistingBlocks(root, isDark ? 'dark' : 'light');
});

View File

@ -11,7 +11,7 @@
* applyDarkClass ( class , reflow )
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { THEME_CHANGE_EVENT } from './theme-transition';
import { onThemeChange, THEME_CHANGE_EVENT } from './theme-transition';
import './index';
describe('startThemeTransition', () => {
@ -44,10 +44,10 @@ describe('startThemeTransition', () => {
});
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
const readyP = Promise.resolve();
const finishedP = Promise.resolve();
const startVT = vi.fn((cb: () => void) => {
const startVT = vi.fn((cb: () => Promise<void>) => {
cbRef.cb = cb;
return { ready: readyP, finished: finishedP, skipTransition: () => {} };
});
@ -69,8 +69,8 @@ describe('startThemeTransition', () => {
// is-theme-transitioning 应在 VT 之前添加
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
// callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb?.();
// callback 里根据 DOM 现状(无 dark)切到 dark(callback 现为 async,需 await)
await cbRef.cb?.();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// finished 后移除 is-theme-transitioning 和 CSS 变量
@ -117,10 +117,10 @@ describe('startThemeTransition', () => {
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', async () => {
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
Object.defineProperty(document, 'startViewTransition', {
value: (cb: () => void) => {
value: (cb: () => Promise<void>) => {
cbRef.cb = cb;
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
},
@ -141,7 +141,7 @@ describe('startThemeTransition', () => {
// 亮→暗:无 dark class,isDark=true
window.__startThemeTransition(0, 0);
cbRef.cb?.();
await cbRef.cb?.();
expect(eventSnapshots).toHaveLength(1);
expect(eventSnapshots[0].isDark).toBe(true);
@ -198,4 +198,64 @@ describe('startThemeTransition', () => {
window.removeEventListener(THEME_CHANGE_EVENT, listener);
});
it('onThemeChange:VT callback 等待 registry 注册的异步回调 Promise', async () => {
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
Object.defineProperty(document, 'startViewTransition', {
value: (cb: () => Promise<void>) => {
cbRef.cb = cb;
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
},
configurable: true,
writable: true,
});
// registry 回调返回一个可控的 Promise,记录它是否在 callback resolve 前完成。
// resolveAsync 用 mutable 容器包裹,绕过 TS 跨闭包的控制流收窄(否则在 ?.() 处
// 被判定为 null → never)。
const resolveAsync: { fn: (() => void) | null } = { fn: null };
const asyncDone = { value: false };
const off = onThemeChange((isDark) => {
void isDark;
return new Promise<void>((resolve) => {
resolveAsync.fn = () => {
asyncDone.value = true;
resolve();
};
});
});
window.__startThemeTransition(0, 0);
// callback 尚未 resolve(async 任务未完成)——VT callback 的 Promise 仍 pending
const callbackPromise = cbRef.cb?.();
expect(asyncDone.value).toBe(false);
// 触发异步任务完成
resolveAsync.fn?.();
await callbackPromise;
expect(asyncDone.value).toBe(true);
off();
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('onThemeChange:降级路径(无 VT)不等 registry 异步回调', async () => {
// 降级路径不 await notifyThemeChange,applyDarkClass 同步完成,registry 后台跑
let registryCalled = false;
const off = onThemeChange(() => {
registryCalled = true;
return new Promise<void>(() => {}); // 永不 resolve
});
// 无 startViewTransition → 降级路径
window.__startThemeTransition(0, 0);
// dark class 应已同步翻转(不等 registry)
expect(document.documentElement.classList.contains('dark')).toBe(true);
// registry 回调被调用(同步触发),但 Promise 未被 await
expect(registryCalled).toBe(true);
off();
});
});

View File

@ -53,16 +53,47 @@ function applyDarkClass(isDark: boolean): void {
}
/**
* (CodeMirror / xterm)
* registry:命令式 /
*
* CustomEvent dispatch 是同步的:listener ,
* setTheme(reconfigure / options.theme =)
* VT NEW ,
*
* 幂等: Dioxus use_effect set_theme , no-op
* THEME_CHANGE_EVENT :
* - (CustomEvent): dispatch, CodeMirror / xterm
* ( listener setTheme, reflow NEW )
* listener ,fire-and-forget
* - registry:回调** Promise**,( VT callback) await ,
* ( mermaid render()) VT
* mermaid.render , VT NEW ,
*/
function notifyThemeChange(isDark: boolean): void {
const themeChangeCallbacks = new Set<(isDark: boolean) => Promise<void> | void>();
/** 注册主题切换回调,返回取消注册函数。回调可返回 Promise,调用方会等待它。 */
export function onThemeChange(cb: (isDark: boolean) => Promise<void> | void): () => void {
themeChangeCallbacks.add(cb);
return () => themeChangeCallbacks.delete(cb);
}
/**
* (CodeMirror / xterm / mermaid)
*
* :
* 1. dispatch THEME_CHANGE_EVENT( CodeMirror / xterm, listener
* setTheme, reflow NEW )
* 2. registry cb, Promise, Promise(VT callback
* await mermaid ),
*
* Promise, await( VT await, await)
*/
function notifyThemeChange(isDark: boolean): Promise<void> {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } }));
const promises: Promise<void>[] = [];
themeChangeCallbacks.forEach((cb) => {
try {
const ret = cb(isDark);
if (ret) promises.push(ret.catch(() => {})); // 单个失败不中断聚合
} catch {
// 同步抛错的 cb 忽略,不中断其他回调
}
});
return Promise.all(promises).then(() => {});
}
/**
@ -90,8 +121,8 @@ export function startThemeTransition(x: number, y: number): void {
if (!hasVT || reduced) {
// 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。
// 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。
notifyThemeChange(isDark);
// 同样通知换肤(不 await,保持瞬切语义;mermaid 等异步组件后台重渲染)。
void notifyThemeChange(isDark);
applyDarkClass(isDark);
return;
}
@ -106,16 +137,19 @@ export function startThemeTransition(x: number, y: number): void {
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
html.classList.add('is-theme-transitioning');
const vt = document.startViewTransition(() => {
// ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。
// 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle
// reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器
notifyThemeChange(isDark);
const vt = document.startViewTransition(async () => {
// ★ 关键:先通知换肤(同步 dispatch 事件让编辑器同步换肤 + 收集 registry 的
// 异步 Promise),再翻 .dark class。顺序不能反——编辑器换肤 + class 翻转必须被
// 同一个 getComputedStyle reflow 捕获进 NEW 快照。
const asyncWork = notifyThemeChange(isDark);
applyDarkClass(isDark);
// 强制同步样式重算:确保 body 的 background-color 解析为目标值,
// 同时 flush 编辑器的同步换肤(CodeMirror <style> / xterm inline bg)。
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
getComputedStyle(document.body).backgroundColor;
// ★ 等 registry 里异步换肤组件(mermaid.render)完成。callback 返回 Promise 时,
// 浏览器等它 resolve 才拍 NEW 快照、播圆形扩散——这样快照里已是新主题流程图。
await asyncWork;
});
vt.ready.catch(() => {});

View File

@ -137,9 +137,9 @@ pub fn PostContent(content_html: String) -> Element {
let fragments = split_content_fragments(&content_html);
// mermaid 流程图主题需随当前生效主题light/dark切换。读 use_resolved_theme()
// 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid幂等
// 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
// 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT)。
// 建立订阅:主题变化时下方 use_effect 重跑,重调 __initMermaid 传入新 theme
// mermaid.ts 用 dataset.mermaidTheme 记住上次渲染主题,检测到主题变化时按缓存
// 源码重渲染mermaid 颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题)。
#[cfg(target_arch = "wasm32")]
let resolved_theme = crate::theme::use_resolved_theme();
@ -166,7 +166,21 @@ pub fn PostContent(content_html: String) -> Element {
} else {
"light".into()
};
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
// VT 动画期间跳过:手动点击主题按钮时,__startThemeTransition 的 VT 回调内已通过
// onThemeChange registry 同步触发 mermaid 重渲染(被 VT 等待,出现在 NEW 快照里)。
// 但本 effect 在 theme.set(next) 后立即触发——早于 VT 回调的异步执行,会抢先改
// 实时 DOM。VT 动画播的是伪元素快照,实时 DOM 改动会穿透伪元素,表现为「圆形
// 还没展开到流程图,流程图就瞬切」。is-theme-transitioning 期间跳过,让 VT 回调
// 内的 registry 重渲染负责;动画结束后此 effect 因 resolved 信号变化重跑(此时
// is-theme-transitioning 已移除),做幂等兜底。照搬 code_runner/runner.rs 的守卫。
let transitioning = window
.document()
.and_then(|d| d.document_element())
.map(|el| el.class_list().contains("is-theme-transitioning"))
.unwrap_or(false);
if !transitioning {
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
}
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;