Compare commits
No commits in common. "302e05fe1fc421703411471b9cd0b619a8a64347" and "45122fc687e67ebca7dc92bae5dc50a7093ba15d" have entirely different histories.
302e05fe1f
...
45122fc687
47
Dockerfile
47
Dockerfile
@ -3,10 +3,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Builder stage: compile the static-linked musl server binary and frontend assets
|
# Builder stage: compile the static-linked musl server binary and frontend assets
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Trixie (Debian 13, glibc 2.41) — required because the prebuilt `dx` v0.7.9
|
FROM rust:1.96-bookworm AS builder
|
||||||
# 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
|
# 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
|
# from inside the container (the host proxy at 127.0.0.1:10808 is unreachable
|
||||||
@ -19,12 +16,6 @@ ARG DEBIAN_SECURITY_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian-security
|
|||||||
ARG NODE_MIRROR=https://registry.npmmirror.com/-/binary/node
|
ARG NODE_MIRROR=https://registry.npmmirror.com/-/binary/node
|
||||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
ARG RS_PROXY=https://rsproxy.cn
|
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. ---
|
# --- Debian apt: rewrite the DEB822 sources to the TUNA mirror. ---
|
||||||
RUN sed -i \
|
RUN sed -i \
|
||||||
@ -88,29 +79,20 @@ RUN rustup target add wasm32-unknown-unknown \
|
|||||||
# Install the Dioxus CLI from the official prebuilt binary (GitHub Releases),
|
# 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
|
# 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
|
# slowest single Docker step). The release tag v0.7.9 matches the crate version
|
||||||
# we previously pinned. The prebuilt dx is a glibc (linux-gnu) binary requiring
|
# we previously pinned. dx runs only in this builder stage (to emit the WASM
|
||||||
# GLIBC_2.39 — that's why the builder stage above uses Trixie (glibc 2.41), not
|
# client bundle); it never enters the static-musl runtime image, so the glibc
|
||||||
# Bookworm (glibc 2.36). dx runs only in this builder stage (to emit the WASM
|
# (linux-gnu) linking of the prebuilt binary is fine here. Each buildx platform
|
||||||
# client bundle); it never enters the static-musl runtime image. Each buildx
|
# leg downloads only its native arch; the sha256 pins the exact artifact
|
||||||
# platform leg downloads only its native arch; the sha256 pins the exact
|
# (supply-chain integrity, verified against the release's .sha256 sidecar).
|
||||||
# artifact (supply-chain integrity, verified against the release's .sha256
|
|
||||||
# sidecar).
|
|
||||||
ARG DX_VERSION=0.7.9
|
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)" \
|
RUN ARCH="$(dpkg --print-architecture)" \
|
||||||
&& case "$ARCH" in \
|
&& case "$ARCH" in \
|
||||||
amd64) DX_TRIPLET=x86_64-unknown-linux-gnu DX_SHA256=3b132551b480bc96f938f9f0d37936ee1190f994977539dcc347eaf38540d005 ;; \
|
amd64) DX_TRIPLET=x86_64-unknown-linux-gnu DX_SHA256=3b132551b480bc96f938f9f0d37936ee1190f994977539dcc347eaf38540d005 ;; \
|
||||||
arm64) DX_TRIPLET=aarch64-unknown-linux-gnu DX_SHA256=8cf14db0b11b43b31dd6d39e71b00e567f2fccfde85ae3a8f7ef0f8745e5ccfb ;; \
|
arm64) DX_TRIPLET=aarch64-unknown-linux-gnu DX_SHA256=8cf14db0b11b43b31dd6d39e71b00e567f2fccfde85ae3a8f7ef0f8745e5ccfb ;; \
|
||||||
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
|
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& DX_URL="${GH_PROXY:+${GH_PROXY}/}https://github.com/DioxusLabs/dioxus/releases/download/v${DX_VERSION}/dx-${DX_TRIPLET}.tar.gz" \
|
&& DX_URL="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 \
|
&& curl -fsSL "${DX_URL}" -o /tmp/dx.tar.gz \
|
||||||
&& echo "${DX_SHA256} /tmp/dx.tar.gz" | sha256sum -c - \
|
&& echo "${DX_SHA256} /tmp/dx.tar.gz" | sha256sum -c - \
|
||||||
&& tar -xzf /tmp/dx.tar.gz -C /usr/local/bin \
|
&& tar -xzf /tmp/dx.tar.gz -C /usr/local/bin \
|
||||||
&& rm /tmp/dx.tar.gz \
|
&& rm /tmp/dx.tar.gz \
|
||||||
@ -125,7 +107,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
|
|||||||
arm64) TW_ARCH=arm64 ;; \
|
arm64) TW_ARCH=arm64 ;; \
|
||||||
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
|
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& GH_URL="${GH_PROXY:+${GH_PROXY}/}https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \
|
&& GH_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \
|
||||||
&& curl -fsSL -o /usr/local/bin/tailwindcss "${GH_URL}" \
|
&& curl -fsSL -o /usr/local/bin/tailwindcss "${GH_URL}" \
|
||||||
&& chmod +x /usr/local/bin/tailwindcss
|
&& chmod +x /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
@ -144,20 +126,15 @@ RUN cd libs && pnpm install --frozen-lockfile
|
|||||||
# Copy the rest of the source tree and build everything.
|
# Copy the rest of the source tree and build everything.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build all 4 JS libs, syntax-highlight CSS, KaTeX CSS + fonts and Tailwind
|
# Build all 4 JS libs, syntax-highlight CSS and Tailwind stylesheet.
|
||||||
# stylesheet. These steps produce the contents of the public/ directory.
|
# These steps produce the contents of the public/ directory.
|
||||||
# Must stay in sync with make build-linux — katex-css was previously missing,
|
RUN make build-libs && make highlight-css && tailwindcss -i input.css -o public/style.css --minify
|
||||||
# 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;
|
# 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
|
# 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
|
# 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.
|
# 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 && \
|
RUN dx build @client --release --debug-symbols=false --wasm-js-cfg false && \
|
||||||
make restore-webp && \
|
|
||||||
mkdir -p /build/dist/public && \
|
mkdir -p /build/dist/public && \
|
||||||
cp -r /build/target/dx/yggdrasil/*/web/public/* /build/dist/public/
|
cp -r /build/target/dx/yggdrasil/*/web/public/* /build/dist/public/
|
||||||
|
|
||||||
|
|||||||
1
Makefile
1
Makefile
@ -1,7 +1,6 @@
|
|||||||
.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
|
.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:
|
build:
|
||||||
@rm -rf static/
|
|
||||||
@cd libs && pnpm install --frozen-lockfile
|
@cd libs && pnpm install --frozen-lockfile
|
||||||
@$(MAKE) build-libs
|
@$(MAKE) build-libs
|
||||||
@$(MAKE) highlight-css
|
@$(MAKE) highlight-css
|
||||||
|
|||||||
76
input.css
76
input.css
@ -274,82 +274,6 @@
|
|||||||
opacity: 1;
|
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 {
|
.entry-cover {
|
||||||
margin-bottom: var(--content-gap-paper);
|
margin-bottom: var(--content-gap-paper);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import type { ThemeName } from '@yggdrasil/shared';
|
|
||||||
import { initAnchorClick } from './anchor-click';
|
import { initAnchorClick } from './anchor-click';
|
||||||
import { scrollToHash } from './hash-scroll';
|
import { scrollToHash } from './hash-scroll';
|
||||||
import { initMermaid } from './mermaid';
|
import { initMermaid } from './mermaid';
|
||||||
import { initPostContent } from './post-content';
|
import { initPostContent } from './post-content';
|
||||||
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
|
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
|
||||||
|
import type { ThemeName } from '@yggdrasil/shared';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__initPostContent: (selector: string) => void;
|
__initPostContent: (selector: string) => void;
|
||||||
__initMermaid: (selector: string, theme: ThemeName) => Promise<void>;
|
__initMermaid: (selector: string, theme: ThemeName) => void;
|
||||||
__initAnchorClick: () => void;
|
__initAnchorClick: () => void;
|
||||||
__scrollToHash: () => void;
|
__scrollToHash: () => void;
|
||||||
__startThemeTransition: (x: number, y: number) => void;
|
__startThemeTransition: (x: number, y: number) => void;
|
||||||
|
|||||||
@ -117,67 +117,4 @@ describe('initMermaid', () => {
|
|||||||
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
|
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-->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-->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-->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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,18 +12,9 @@
|
|||||||
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 DOM。
|
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 DOM。
|
||||||
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
|
* mermaid 无官方 SSR 支持,纯客户端渲染;服务端 markdown.rs 只产出带
|
||||||
* `language-mermaid` class 的普通代码块,不挂 data 属性。
|
* `language-mermaid` class 的普通代码块,不挂 data 属性。
|
||||||
*
|
|
||||||
* 主题切换:mermaid 把颜色/主题变量烤进 SVG 内联样式,无法靠 CSS 原地改主题。
|
|
||||||
* 唯一办法是移除旧 SVG → 用新主题重新 render → 插入新 SVG。post_content.rs 的
|
|
||||||
* use_effect 读 use_resolved_theme() 建立订阅,主题切换时重跑、重调 __initMermaid
|
|
||||||
* 传入新 theme;本模块用 dataset.mermaidTheme 记住上次渲染主题,主题变化时触发重渲染。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ThemeName } from '@yggdrasil/shared';
|
import type { ThemeName } from '@yggdrasil/shared';
|
||||||
import { onThemeChange } from './theme-transition';
|
|
||||||
|
|
||||||
/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */
|
|
||||||
const POST_CONTENT_SELECTOR = '.post-content';
|
|
||||||
|
|
||||||
type MermaidApi = {
|
type MermaidApi = {
|
||||||
initialize: (config: Record<string, unknown>) => void;
|
initialize: (config: Record<string, unknown>) => void;
|
||||||
@ -38,9 +29,6 @@ declare global {
|
|||||||
|
|
||||||
let mermaidPromise: Promise<MermaidApi> | null = null;
|
let mermaidPromise: Promise<MermaidApi> | null = null;
|
||||||
|
|
||||||
/** render id 自增计数器,保证每次 render 生成唯一 id,避开 mermaid 残留节点冲突。 */
|
|
||||||
let renderCounter = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态加载 mermaid 独立 IIFE bundle 的底层函数(可注入以便测试)。
|
* 动态加载 mermaid 独立 IIFE bundle 的底层函数(可注入以便测试)。
|
||||||
*
|
*
|
||||||
@ -87,33 +75,6 @@ function loadMermaid(): Promise<MermaidApi> {
|
|||||||
return mermaidPromise;
|
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:进入视口才渲染。
|
* 为单个 mermaid <pre> 注册 IntersectionObserver:进入视口才渲染。
|
||||||
*
|
*
|
||||||
@ -140,32 +101,33 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
|
|||||||
/**
|
/**
|
||||||
* 初始化文章正文里的 mermaid 代码块。
|
* 初始化文章正文里的 mermaid 代码块。
|
||||||
*
|
*
|
||||||
* 两条互斥路径(渲染后 <code> 被 SVG 替换,故两选择器命中的 pre 不会重叠):
|
|
||||||
* 1. 仍含 `<code class="language-mermaid">` 的 pre(未渲染)→ 缓存源码 +
|
|
||||||
* IntersectionObserver 进视口懒加载。
|
|
||||||
* 2. 已渲染(pre 内容是 SVG,dataset.mermaid-rendered 标记)→ 同主题幂等跳过
|
|
||||||
* (上下篇切换复用组件实例时 post_content.rs 的 effect 会重调本函数);
|
|
||||||
* 主题变化则取缓存源码重渲染(bundle 已加载,无需 IntersectionObserver)。
|
|
||||||
*
|
|
||||||
* @param selector 文章正文容器选择器(如 '.post-content')
|
* @param selector 文章正文容器选择器(如 '.post-content')
|
||||||
* @param theme 当前生效主题,传给 mermaid 适配暗色
|
* @param theme 当前生效主题,传给 mermaid 适配暗色
|
||||||
*/
|
*/
|
||||||
export function initMermaid(selector: string, theme: ThemeName): Promise<void> {
|
export function initMermaid(selector: string, theme: ThemeName): void {
|
||||||
const root = document.querySelector(selector);
|
const root = document.querySelector(selector);
|
||||||
if (!root) return Promise.resolve();
|
if (!root) return;
|
||||||
|
|
||||||
// 路径 1:未渲染的块(<code> 还在)。
|
|
||||||
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
|
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
|
||||||
blocks.forEach((code) => {
|
if (blocks.length === 0) return;
|
||||||
|
|
||||||
|
blocks.forEach((code, i) => {
|
||||||
const pre = code.parentElement as HTMLPreElement | null;
|
const pre = code.parentElement as HTMLPreElement | null;
|
||||||
if (!pre) return;
|
if (!pre) return;
|
||||||
if (pre.dataset.mermaidRendered) return; // 理论不命中(renderBlock 同步替换 innerHTML),防御
|
if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
|
||||||
|
|
||||||
const source = code.textContent || '';
|
const source = code.textContent || '';
|
||||||
pre.dataset.mermaidSource = source;
|
|
||||||
observeBlock(pre, async () => {
|
observeBlock(pre, async () => {
|
||||||
try {
|
try {
|
||||||
await renderBlock(pre, source, theme);
|
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';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
|
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
|
||||||
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
|
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
|
||||||
@ -174,52 +136,4 @@ export function initMermaid(selector: string, theme: ThemeName): Promise<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:所有需要重渲染的块完成后 resolve。onThemeChange 订阅返回它,
|
|
||||||
* 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');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
* 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。
|
* 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { onThemeChange, THEME_CHANGE_EVENT } from './theme-transition';
|
import { THEME_CHANGE_EVENT } from './theme-transition';
|
||||||
import './index';
|
import './index';
|
||||||
|
|
||||||
describe('startThemeTransition', () => {
|
describe('startThemeTransition', () => {
|
||||||
@ -44,10 +44,10 @@ describe('startThemeTransition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
|
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
|
||||||
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
|
const cbRef: { cb: (() => void) | null } = { cb: null };
|
||||||
const readyP = Promise.resolve();
|
const readyP = Promise.resolve();
|
||||||
const finishedP = Promise.resolve();
|
const finishedP = Promise.resolve();
|
||||||
const startVT = vi.fn((cb: () => Promise<void>) => {
|
const startVT = vi.fn((cb: () => void) => {
|
||||||
cbRef.cb = cb;
|
cbRef.cb = cb;
|
||||||
return { ready: readyP, finished: finishedP, skipTransition: () => {} };
|
return { ready: readyP, finished: finishedP, skipTransition: () => {} };
|
||||||
});
|
});
|
||||||
@ -69,8 +69,8 @@ describe('startThemeTransition', () => {
|
|||||||
// is-theme-transitioning 应在 VT 之前添加
|
// is-theme-transitioning 应在 VT 之前添加
|
||||||
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
|
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
|
||||||
|
|
||||||
// callback 里根据 DOM 现状(无 dark)切到 dark(callback 现为 async,需 await)
|
// callback 里根据 DOM 现状(无 dark)切到 dark
|
||||||
await cbRef.cb?.();
|
cbRef.cb?.();
|
||||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
|
||||||
// finished 后移除 is-theme-transitioning 和 CSS 变量
|
// finished 后移除 is-theme-transitioning 和 CSS 变量
|
||||||
@ -117,10 +117,10 @@ describe('startThemeTransition', () => {
|
|||||||
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', async () => {
|
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => {
|
||||||
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
|
const cbRef: { cb: (() => void) | null } = { cb: null };
|
||||||
Object.defineProperty(document, 'startViewTransition', {
|
Object.defineProperty(document, 'startViewTransition', {
|
||||||
value: (cb: () => Promise<void>) => {
|
value: (cb: () => void) => {
|
||||||
cbRef.cb = cb;
|
cbRef.cb = cb;
|
||||||
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
|
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
|
||||||
},
|
},
|
||||||
@ -141,7 +141,7 @@ describe('startThemeTransition', () => {
|
|||||||
|
|
||||||
// 亮→暗:无 dark class,isDark=true
|
// 亮→暗:无 dark class,isDark=true
|
||||||
window.__startThemeTransition(0, 0);
|
window.__startThemeTransition(0, 0);
|
||||||
await cbRef.cb?.();
|
cbRef.cb?.();
|
||||||
|
|
||||||
expect(eventSnapshots).toHaveLength(1);
|
expect(eventSnapshots).toHaveLength(1);
|
||||||
expect(eventSnapshots[0].isDark).toBe(true);
|
expect(eventSnapshots[0].isDark).toBe(true);
|
||||||
@ -198,64 +198,4 @@ describe('startThemeTransition', () => {
|
|||||||
|
|
||||||
window.removeEventListener(THEME_CHANGE_EVENT, listener);
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -53,47 +53,16 @@ function applyDarkClass(isDark: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题切换 registry:命令式 / 异步换肤组件注册的回调。
|
* 同步通知命令式换肤的组件(CodeMirror / xterm)切换主题。
|
||||||
*
|
*
|
||||||
* 与 THEME_CHANGE_EVENT 事件并存:
|
* CustomEvent 的 dispatch 是同步的:listener 在本函数返回前执行完毕,
|
||||||
* - 事件(CustomEvent):同步 dispatch,供 CodeMirror / xterm 等同步换肤组件用
|
* 故编辑器的 setTheme(reconfigure / options.theme =) 在调用方继续前已完成。
|
||||||
* (它们在 listener 里同步 setTheme,被同一 reflow 捕获进 NEW 快照)。事件拿不到
|
* 这对 VT 至关重要——必须在 NEW 快照捕获前完成换肤,否则快照里仍是旧色。
|
||||||
* listener 返回值,fire-and-forget。
|
*
|
||||||
* - registry:回调**可返回 Promise**,调用方(如 VT callback)能 await 它,等异步换肤
|
* 幂等:与 Dioxus use_effect 驱动的 set_theme 并存,重复设置相同主题是 no-op。
|
||||||
* 组件(如 mermaid 的 render())完成。这是让异步渲染内容参与 VT 动画的关键——
|
|
||||||
* mermaid.render 是异步的,必须在 VT 拍 NEW 快照前完成,否则快照里仍是旧图。
|
|
||||||
*/
|
*/
|
||||||
const themeChangeCallbacks = new Set<(isDark: boolean) => Promise<void> | void>();
|
function notifyThemeChange(isDark: boolean): 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 } }));
|
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(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,8 +90,8 @@ export function startThemeTransition(x: number, y: number): void {
|
|||||||
|
|
||||||
if (!hasVT || reduced) {
|
if (!hasVT || reduced) {
|
||||||
// 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。
|
// 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。
|
||||||
// 同样通知换肤(不 await,保持瞬切语义;mermaid 等异步组件后台重渲染)。
|
// 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。
|
||||||
void notifyThemeChange(isDark);
|
notifyThemeChange(isDark);
|
||||||
applyDarkClass(isDark);
|
applyDarkClass(isDark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -137,19 +106,16 @@ export function startThemeTransition(x: number, y: number): void {
|
|||||||
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
|
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
|
||||||
html.classList.add('is-theme-transitioning');
|
html.classList.add('is-theme-transitioning');
|
||||||
|
|
||||||
const vt = document.startViewTransition(async () => {
|
const vt = document.startViewTransition(() => {
|
||||||
// ★ 关键:先通知换肤(同步 dispatch 事件让编辑器同步换肤 + 收集 registry 的
|
// ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。
|
||||||
// 异步 Promise),再翻 .dark class。顺序不能反——编辑器换肤 + class 翻转必须被
|
// 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle
|
||||||
// 同一个 getComputedStyle reflow 捕获进 NEW 快照。
|
// reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器。
|
||||||
const asyncWork = notifyThemeChange(isDark);
|
notifyThemeChange(isDark);
|
||||||
applyDarkClass(isDark);
|
applyDarkClass(isDark);
|
||||||
// 强制同步样式重算:确保 body 的 background-color 解析为目标值,
|
// 强制同步样式重算:确保 body 的 background-color 解析为目标值,
|
||||||
// 同时 flush 编辑器的同步换肤(CodeMirror <style> / xterm inline bg)。
|
// 同时 flush 编辑器的同步换肤(CodeMirror <style> / xterm inline bg)。
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
getComputedStyle(document.body).backgroundColor;
|
getComputedStyle(document.body).backgroundColor;
|
||||||
// ★ 等 registry 里异步换肤组件(mermaid.render)完成。callback 返回 Promise 时,
|
|
||||||
// 浏览器等它 resolve 才拍 NEW 快照、播圆形扩散——这样快照里已是新主题流程图。
|
|
||||||
await asyncWork;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
vt.ready.catch(() => {});
|
vt.ready.catch(() => {});
|
||||||
|
|||||||
@ -137,9 +137,9 @@ pub fn PostContent(content_html: String) -> Element {
|
|||||||
let fragments = split_content_fragments(&content_html);
|
let fragments = split_content_fragments(&content_html);
|
||||||
|
|
||||||
// mermaid 流程图主题需随当前生效主题(light/dark)切换。读 use_resolved_theme()
|
// mermaid 流程图主题需随当前生效主题(light/dark)切换。读 use_resolved_theme()
|
||||||
// 建立订阅:主题变化时下方 use_effect 重跑,重调 __initMermaid 传入新 theme;
|
// 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid(幂等,
|
||||||
// mermaid.ts 用 dataset.mermaidTheme 记住上次渲染主题,检测到主题变化时按缓存
|
// 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
|
||||||
// 源码重渲染(mermaid 颜色烤进 SVG 内联样式,无法靠 CSS 原地切主题)。
|
// 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT)。
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let resolved_theme = crate::theme::use_resolved_theme();
|
let resolved_theme = crate::theme::use_resolved_theme();
|
||||||
|
|
||||||
@ -166,21 +166,7 @@ pub fn PostContent(content_html: String) -> Element {
|
|||||||
} else {
|
} else {
|
||||||
"light".into()
|
"light".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()]);
|
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
|
||||||
}
|
|
||||||
|
|
||||||
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
|
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
|
||||||
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;
|
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user