diff --git a/Makefile b/Makefile index 1651f67..1089835 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,30 @@ PKG = github.com/kooksee/markview COMMIT = $(shell git rev-parse --short HEAD) BUILD_LDFLAGS = "-s -w -X $(PKG)/version.Revision=$(COMMIT)" +MARP = pnpm dlx @marp-team/marp-cli +SLIDES_FILE ?= docs/slides/tech-talk-template.md +SLIDES_THEME ?= docs/slides/theme-markview.css +SLIDES_PDF ?= docs/slides/tech-talk-template.pdf +SLIDES_PPTX ?= docs/slides/tech-talk-template.pptx default: test ci: depsdev generate test +slides-preview: + $(MARP) $(SLIDES_FILE) --theme-set $(SLIDES_THEME) --preview + +slides-pdf: + $(MARP) $(SLIDES_FILE) --theme-set $(SLIDES_THEME) --pdf -o $(SLIDES_PDF) + +slides-pptx: + $(MARP) $(SLIDES_FILE) --theme-set $(SLIDES_THEME) --pptx -o $(SLIDES_PPTX) + generate: go generate ./internal/static/ test: - cd frontend && pnpm install && pnpm run test:coverage + cd frontend && sh scripts/pnpm-install-safe.sh && pnpm run test:coverage go test ./... -coverprofile=coverage.out -covermode=count -count=1 build: generate @@ -27,15 +41,15 @@ screenshot: build cd frontend && pnpm run screenshots lint: - cd frontend && pnpm install && pnpm run lint + cd frontend && sh scripts/pnpm-install-safe.sh && pnpm run lint golangci-lint run ./... go vet -vettool=`which gostyle` -gostyle.config=$(PWD)/.gostyle.yml ./... fmt: - cd frontend && pnpm install && pnpm run fmt + cd frontend && sh scripts/pnpm-install-safe.sh && pnpm run fmt fmt-check: - cd frontend && pnpm install && pnpm run fmt:check + cd frontend && sh scripts/pnpm-install-safe.sh && pnpm run fmt:check depsdev: go install github.com/Songmu/gocredits/cmd/gocredits@latest @@ -51,4 +65,4 @@ credits: depsdev generate prerelease_for_tagpr: credits git add CHANGELOG.md CREDITS go.mod go.sum -.PHONY: default ci generate test build dev screenshot lint fmt fmt-check depsdev credits prerelease_for_tagpr +.PHONY: default ci generate test build dev screenshot lint fmt fmt-check depsdev credits prerelease_for_tagpr slides-preview slides-pdf slides-pptx diff --git a/README.md b/README.md index 60f316a..5f1cbb4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) - 宽布局 宽版 / 窄布局 窄版阅读宽度切换 - 原文 原始 Markdown 视图 +- 内置 Slides 演示模式(`---` 分页、键盘翻页、全屏演示) - 复制 内容复制(Markdown / 文本 / HTML) - 重启 服务重启并保留会话 - 自动会话备份与恢复 @@ -124,6 +125,42 @@ $ markview --unwatch '/Users/you/project/**/*.md' # 按绝对路径 | ------------------------------------------------------- | ------------------------------------------------------- | | ![平铺视图](images/sidebar-flat.png) | ![树形视图](images/sidebar-tree.png) | +### 内置 Slides 演示模式 + +在文档右侧工具列点击 `Slides` 按钮即可进入演示模式。 + +- 使用 `---` 分隔幻灯片(代码块内的 `---` 不会被当作分页) +- 每次只渲染当前页,适合现场讲解 +- 支持全屏演示与快捷键导航 + +示例: + +```md +# 封面 + +这是第一页 + +--- + +# 第二页 + +这是第二页 +``` + +常用操作: + +| 操作 | 快捷键 / 交互 | +| --------------------- | --------------------------------------------------------- | +| 下一页 | `→` / `PageDown` / `Space` / `Enter` / 点击幻灯片空白区域 | +| 上一页 | `←` / `PageUp` | +| 首/末页 | `Home` / `End` | +| 切换全屏 | `F` | +| 退出 | `Esc`(全屏时优先退出全屏,再次按下退出 Slides) | +| 固定/取消固定演示控件 | `H` | + +> [!TIP] +> 全屏状态下,演示控件会在短暂无操作后自动隐藏;移动鼠标、触控或按键会再次显示。 + ### 启动与停止 `markview` 默认后台运行,命令会立即返回,不阻塞当前终端。 @@ -250,6 +287,29 @@ $ markview --status --json $ make build ``` +## 可选:Markdown PPT 导出(Marp) + +如果你需要导出离线讲稿(PDF/PPTX),仓库提供了 `docs/slides/` 的 Marp 模板与主题,可通过 Makefile 预览和导出: + +```console +$ make slides-preview +$ make slides-pdf +$ make slides-pptx +``` + +默认使用: + +- `docs/slides/tech-talk-template.md` +- `docs/slides/theme-markview.css` + +你也可以覆盖输入文件(示例): + +```console +$ make slides-pdf SLIDES_FILE=docs/slides/my-talk.md +``` + +更多模板说明见:`docs/slides/README.md` + ## 中文文档 为了便于本地阅读和二次开发,仓库提供了以下中文文档: diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md index aef3513..d4e8565 100644 --- a/docs/markdown-capabilities.md +++ b/docs/markdown-capabilities.md @@ -63,6 +63,11 @@ - ToC 面板(按 H1-H6 标题导航) - 当前标题高亮与平滑滚动定位 - 原文视图(Raw)与渲染视图切换 +- Slides 演示视图(按 `---` 分页,代码块内分隔线自动忽略) + - 支持按钮翻页与键盘翻页(`←/→`、`PageUp/PageDown`、`Space/Enter`、`Home/End`) + - 支持 `F` 全屏、`Esc` 退出(全屏时优先退出全屏) + - 支持点击幻灯片空白区域下一页 + - 全屏下支持演示控件自动隐藏与 `H` 固定/取消固定 - 全局全文搜索后可跳转到目标文档并定位命中内容 ## 4. 结构化关系能力 diff --git a/docs/quick-start-visual.md b/docs/quick-start-visual.md index d612935..a9dad96 100644 --- a/docs/quick-start-visual.md +++ b/docs/quick-start-visual.md @@ -35,6 +35,16 @@ $ markview -p 16275 -b localhost --no-open testdata/basic.md testdata/mermaid-fl - 图表块支持缩放/全屏等交互(不同图表能力略有差异) - 目录、原文、复制、导出等按钮在文档右侧工具列 +### 2.1 快速体验内置演示模式(Slides) + +在任意 Markdown 文档右侧点击 `Slides` 按钮即可进入演示模式: + +- 使用 `---` 进行分页(代码块内 `---` 不会触发分页) +- `F` 切换全屏,`Esc` 退出(全屏时先退全屏) +- 可用 `←/→`、`Space`、`PageUp/PageDown` 翻页 +- 点击幻灯片空白区域也可进入下一页 +- 全屏下控件会自动隐藏;`H` 可固定/取消固定控件 + ## 3) 体验结构可视化模式 在页面顶部工具栏点击图谱相关按钮: diff --git a/docs/slides/README.md b/docs/slides/README.md new file mode 100644 index 0000000..d815735 --- /dev/null +++ b/docs/slides/README.md @@ -0,0 +1,39 @@ +# Markdown PPT(Marp)模板 + +本目录提供一套可直接开讲的 Marp 幻灯片模板。 + +## 文件说明 + +- `tech-talk-template.md`:演示模板(16:9、分页、结构化内容) +- `theme-markview.css`:深色中文主题 + +## 本地预览 + +在仓库根目录执行: + +make slides-preview + +## 导出 PDF + +make slides-pdf + +## 导出 PPTX + +make slides-pptx + +## 自定义输入文件 + +make slides-preview SLIDES_FILE=docs/slides/my-talk.md +make slides-pdf SLIDES_FILE=docs/slides/my-talk.md SLIDES_PDF=docs/slides/my-talk.pdf +make slides-pptx SLIDES_FILE=docs/slides/my-talk.md SLIDES_PPTX=docs/slides/my-talk.pptx + +## 不走 Makefile(可选) + +pnpm dlx @marp-team/marp-cli docs/slides/tech-talk-template.md --theme-set docs/slides/theme-markview.css --preview + +## 快速改造建议 + +1. 先改标题页与目录页 +2. 每页只放一个核心观点 +3. 一页最多 5~7 条要点 +4. 代码页只展示最关键片段 diff --git a/docs/slides/tech-talk-template.md b/docs/slides/tech-talk-template.md new file mode 100644 index 0000000..b66c62d --- /dev/null +++ b/docs/slides/tech-talk-template.md @@ -0,0 +1,112 @@ +--- +marp: true +theme: markview +paginate: true +size: 16:9 +footer: 'markview · 技术分享模板' +--- + + + +# 标题:你的主题 + +### 副标题(可选) + +**演讲者**:你的名字 +**日期**:2026-05-11 + +--- + +## 今天你将听到什么 + +1. 背景与问题 +2. 核心方案 +3. 关键实现 +4. 实验结果 +5. 下一步计划 + +--- + +## 背景与问题 + +- 现状:一句话描述当前场景 +- 痛点:列 2~3 个关键问题 +- 目标:明确可衡量目标(如耗时、成功率、体验) + +> 建议:每页只讲一个主观点。 + +--- + +## 方案总览 + +- 方案 A:最小可行,先跑通 +- 方案 B:工程化增强,降低长期成本 +- 取舍:开发速度 vs 维护复杂度 + +| 方案 | 开发成本 | 维护成本 | 推荐 | +| ---- | -------- | -------- | --------- | +| A | 低 | 中 | ✅ | +| B | 中 | 低 | ✅(迭代) | + +--- + +## 关键实现(代码示例) + +```ts +interface BuildOptions { + platform: string; + arch: string; +} + +export function resolveRuntimeKey(opts: BuildOptions): string { + return `${opts.platform}-${opts.arch}`; +} +``` + +- 把复杂逻辑封装成可测试函数 +- 优先让失败可观测(日志/错误信息) + +--- + +## 效果对比 + +- 改造前:偶发失败、跨设备不稳定 +- 改造后:自动自愈、重复执行稳定 + +**关键收益** + +- 交付稳定性提升 +- 切机/换架构成本下降 +- 团队协作摩擦减少 + +--- + +## 风险与边界 + +- 边界 1:网络异常导致依赖下载失败 +- 边界 2:锁文件版本漂移 +- 边界 3:本地缓存污染 + +**应对策略** + +- 明确重试与回滚策略 +- 固定关键版本 +- 保留一键修复入口 + +--- + +## 下一步计划 + +- [ ] 增加 CI 校验 +- [ ] 补齐回归测试 +- [ ] 增加文档与操作手册 + +Tip:最后一页最好给出明确的行动项。 + +--- + + + +# Q & A + +谢谢大家 🙌 diff --git a/docs/slides/tech-talk-template.pdf b/docs/slides/tech-talk-template.pdf new file mode 100644 index 0000000..5b1b51c Binary files /dev/null and b/docs/slides/tech-talk-template.pdf differ diff --git a/docs/slides/theme-markview.css b/docs/slides/theme-markview.css new file mode 100644 index 0000000..641f1fc --- /dev/null +++ b/docs/slides/theme-markview.css @@ -0,0 +1,132 @@ +/* @theme markview */ +@import 'default'; + +:root { + --mv-bg: #0f172a; + --mv-bg-soft: #1e293b; + --mv-fg: #e2e8f0; + --mv-muted: #94a3b8; + --mv-accent: #38bdf8; + --mv-accent-2: #a78bfa; + --mv-border: #334155; + --mv-code-bg: #111827; +} + +section { + background: linear-gradient(160deg, var(--mv-bg) 0%, #111827 60%, #0b1020 100%); + color: var(--mv-fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; + padding: 56px 64px; + font-size: 32px; + line-height: 1.45; +} + +section.lead { + justify-content: center; +} + +h1, +h2, +h3 { + margin: 0 0 0.45em 0; + line-height: 1.2; + letter-spacing: 0.01em; +} + +h1 { + color: #f8fafc; + font-size: 1.6em; +} + +h2 { + color: #e2e8f0; + font-size: 1.25em; +} + +h3 { + color: #cbd5e1; + font-size: 1.05em; +} + +p, +li { + color: var(--mv-fg); +} + +strong { + color: #f8fafc; +} + +a { + color: var(--mv-accent); +} + +ul, +ol { + margin-top: 0.35em; +} + +li { + margin: 0.25em 0; +} + +blockquote { + border-left: 4px solid var(--mv-accent-2); + background: rgba(148, 163, 184, 0.08); + color: #dbeafe; + padding: 0.6em 0.9em; + border-radius: 8px; +} + +code { + background: rgba(148, 163, 184, 0.14); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 6px; + padding: 0.08em 0.32em; + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.85em; +} + +pre { + margin: 0.6em 0; + border-radius: 10px; + border: 1px solid var(--mv-border); + background: var(--mv-code-bg); +} + +pre code { + border: 0; + background: transparent; + padding: 0; + font-size: 0.78em; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 0.75em; +} + +th, +td { + border: 1px solid var(--mv-border); + padding: 0.45em 0.55em; +} + +th { + background: rgba(56, 189, 248, 0.15); +} + +small { + color: var(--mv-muted); +} + +footer { + color: var(--mv-muted); + font-size: 0.42em; +} + +section::after { + color: rgba(148, 163, 184, 0.8); + font-size: 0.45em; +} \ No newline at end of file diff --git a/frontend/scripts/pnpm-install-safe.sh b/frontend/scripts/pnpm-install-safe.sh new file mode 100644 index 0000000..303a6fa --- /dev/null +++ b/frontend/scripts/pnpm-install-safe.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh + +set -eu + +STAMP_FILE="node_modules/.markview-node-arch" +CURRENT_ARCH="$(pnpm node -p "process.platform + '-' + process.arch")" + +if [ -d "node_modules" ]; then + if [ ! -f "$STAMP_FILE" ]; then + echo "markview: node_modules 缺少架构标记,执行一次清理重装 ($CURRENT_ARCH)" + rm -rf node_modules + else + PREV_ARCH="$(cat "$STAMP_FILE" 2>/dev/null || true)" + if [ "$PREV_ARCH" != "$CURRENT_ARCH" ]; then + echo "markview: 检测到架构切换: $PREV_ARCH -> $CURRENT_ARCH,清理 node_modules 后重装" + rm -rf node_modules + fi + fi +fi + +pnpm install + +mkdir -p "$(dirname "$STAMP_FILE")" +printf "%s\n" "$CURRENT_ARCH" > "$STAMP_FILE" diff --git a/frontend/src/components/MarkdownViewer.slides.test.tsx b/frontend/src/components/MarkdownViewer.slides.test.tsx new file mode 100644 index 0000000..d4efb90 --- /dev/null +++ b/frontend/src/components/MarkdownViewer.slides.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MarkdownViewer } from "./MarkdownViewer"; +import { fetchFileContent, openRelativeFile } from "../hooks/useApi"; + +vi.mock("../hooks/useApi", () => ({ + fetchFileContent: vi.fn(), + openRelativeFile: vi.fn(), +})); + +vi.mock("./TocToggle", () => ({ + TocToggle: () => null, +})); + +vi.mock("./RawToggle", () => ({ + RawToggle: () => null, +})); + +vi.mock("./CopyButton", () => ({ + CopyButton: () => null, +})); + +vi.mock("./PdfExportButton", () => ({ + PdfExportButton: () => null, +})); + +vi.mock("./RemoveButton", () => ({ + RemoveButton: () => null, +})); + +vi.mock("./BacklinksPanel", () => ({ + BacklinksPanel: () => null, +})); + +describe("MarkdownViewer slides mode", () => { + let requestFullscreenMock: ReturnType; + let exitFullscreenMock: ReturnType; + let fullscreenElement: Element | null; + + beforeEach(() => { + vi.clearAllMocks(); + fullscreenElement = null; + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, + }); + requestFullscreenMock = vi.fn().mockImplementation(function (this: HTMLElement) { + fullscreenElement = this; + return Promise.resolve(); + }); + exitFullscreenMock = vi.fn().mockImplementation(() => { + fullscreenElement = null; + return Promise.resolve(); + }); + Object.defineProperty(document, "exitFullscreen", { + configurable: true, + value: exitFullscreenMock, + }); + Object.defineProperty(HTMLElement.prototype, "requestFullscreen", { + configurable: true, + value: requestFullscreenMock, + }); + vi.mocked(fetchFileContent).mockResolvedValue({ + content: `# 封面\n\n第一页内容\n\n---\n\n# 第二页\n\n第二页内容`, + baseDir: "/tmp", + }); + vi.mocked(openRelativeFile).mockResolvedValue({ + id: "file-2", + name: "ok.md", + path: "/tmp/ok.md", + }); + }); + + it("enters slides mode and flips pages by button", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + + await user.click(screen.getByRole("button", { name: "Slides" })); + + await waitFor(() => { + expect(screen.getByText(/PPT 模式/)).toBeInTheDocument(); + expect(screen.getByText("第一页内容")).toBeInTheDocument(); + expect(screen.queryByText("第二页内容")).not.toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "下一页" })); + + await waitFor(() => { + expect(screen.getByText("第二页内容")).toBeInTheDocument(); + expect(screen.queryByText("第一页内容")).not.toBeInTheDocument(); + }); + }); + + it("supports keyboard navigation in slides mode", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + await user.click(screen.getByRole("button", { name: "Slides" })); + + fireEvent.keyDown(window, { key: "ArrowRight" }); + await waitFor(() => { + expect(screen.getByText("第二页内容")).toBeInTheDocument(); + }); + + fireEvent.keyDown(window, { key: "ArrowLeft" }); + await waitFor(() => { + expect(screen.getByText("第一页内容")).toBeInTheDocument(); + }); + }); + + it("enters fullscreen when clicking fullscreen button", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + await user.click(screen.getByRole("button", { name: "Slides" })); + + await user.click(screen.getByRole("button", { name: "全屏展示" })); + expect(requestFullscreenMock).toHaveBeenCalledOnce(); + }); + + it("goes to next slide when clicking slide body", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + await user.click(screen.getByRole("button", { name: "Slides" })); + + const slidePage = document.querySelector(".markdown-slide-page") as HTMLElement; + expect(slidePage).toBeTruthy(); + await user.click(slidePage); + + await waitFor(() => { + expect(screen.getByText("第二页内容")).toBeInTheDocument(); + }); + }); + + it("exits fullscreen before leaving slides on Escape", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + await user.click(screen.getByRole("button", { name: "Slides" })); + await user.click(screen.getByRole("button", { name: "全屏展示" })); + expect(requestFullscreenMock).toHaveBeenCalledOnce(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(exitFullscreenMock).toHaveBeenCalledOnce(); + expect(screen.getByText(/PPT 模式/)).toBeInTheDocument(); + }); + + it("auto-hides overlay controls in fullscreen after inactivity", async () => { + const user = userEvent.setup(); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + await screen.findByText("第一页内容"); + await user.click(screen.getByRole("button", { name: "Slides" })); + await user.click(screen.getByRole("button", { name: "全屏展示" })); + document.dispatchEvent(new Event("fullscreenchange")); + + const shell = screen.getByTestId("markdown-slide-shell"); + expect(shell.className).not.toContain("markdown-slide-shell--overlay-hidden"); + + await waitFor(() => { + expect(shell.className).toContain("markdown-slide-shell--overlay-hidden"); + }, { + timeout: 4500, + }); + }); + +}); diff --git a/frontend/src/components/MarkdownViewer.test.tsx b/frontend/src/components/MarkdownViewer.test.tsx index 34a3a20..ce71fed 100644 --- a/frontend/src/components/MarkdownViewer.test.tsx +++ b/frontend/src/components/MarkdownViewer.test.tsx @@ -16,6 +16,10 @@ vi.mock("./RawToggle", () => ({ RawToggle: () => null, })); +vi.mock("./SlidesToggle", () => ({ + SlidesToggle: () => null, +})); + vi.mock("./CopyButton", () => ({ CopyButton: () => null, })); diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index 53923e1..50bba53 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -14,6 +14,7 @@ import mermaid from "mermaid"; import { fetchFileContent, openRelativeFile } from "../hooks/useApi"; import { getMermaidSettings, useMermaidSettingsRevision, type MermaidSettings } from "../hooks/useMermaidSettings"; import { RawToggle } from "./RawToggle"; +import { SlidesToggle } from "./SlidesToggle"; import { TocToggle } from "./TocToggle"; import { CopyButton } from "./CopyButton"; import { PdfExportButton } from "./PdfExportButton"; @@ -1724,6 +1725,52 @@ function RawView({ content }: { content: string }) { ); } +function splitMarkdownSlides(markdown: string): string[] { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const slides: string[] = []; + let current: string[] = []; + let inFence = false; + let fenceChar: "`" | "~" | "" = ""; + let fenceLen = 0; + + const flush = () => { + const text = current.join("\n").trim(); + if (text.length > 0) slides.push(text); + current = []; + }; + + for (const line of lines) { + const fence = line.match(/^\s*(`{3,}|~{3,})/); + if (fence) { + const marker = fence[1] ?? ""; + if (!inFence) { + inFence = true; + fenceChar = (marker[0] as "`" | "~") ?? ""; + fenceLen = marker.length; + } else { + const expected = fenceChar.repeat(fenceLen); + if (line.trimStart().startsWith(expected)) { + inFence = false; + fenceChar = ""; + fenceLen = 0; + } + } + current.push(line); + continue; + } + + if (!inFence && /^\s*---\s*$/.test(line)) { + flush(); + continue; + } + + current.push(line); + } + + flush(); + return slides.length > 0 ? slides : [markdown.trim()].filter((s) => s.length > 0); +} + interface CollapsibleHeadingProps extends React.HTMLAttributes { as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; collapsed: boolean; @@ -1782,9 +1829,16 @@ export function MarkdownViewer({ const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [isRawView, setIsRawView] = useState(false); + const [isSlidesView, setIsSlidesView] = useState(false); + const [isSlidesFullscreen, setIsSlidesFullscreen] = useState(false); + const [isSlidesOverlayVisible, setIsSlidesOverlayVisible] = useState(true); + const [isSlidesOverlayPinned, setIsSlidesOverlayPinned] = useState(false); + const [slideIndex, setSlideIndex] = useState(0); const [collapsedHeadingIds, setCollapsedHeadingIds] = useState>(() => new Set()); const [linkOpenError, setLinkOpenError] = useState(null); const articleRef = useRef(null); + const slideShellRef = useRef(null); + const slidesOverlayTimerRef = useRef(null); const [prevFetchKey, setPrevFetchKey] = useState({ fileId, revision }); if (fileId !== prevFetchKey.fileId || revision !== prevFetchKey.revision) { @@ -1853,145 +1907,107 @@ export function MarkdownViewer({ ); const components: Components = useMemo( - () => ({ - pre: ({ children }) => <>{children}, - h1: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - h2: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - h3: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - h4: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - h5: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - h6: ({ node: _node, children, id, className, ...props }) => ( - toggleHeadingCollapse(id) : undefined} - {...props} - > - {children} - - ), - code: ({ className, children, ...props }) => { - const language = extractLanguage(className); - const code = String(children).replace(/\n$/, ""); - const isBlock = String(children).endsWith("\n"); - if (language) { - if (language === "mermaid") { - return ; - } - if (language === "svgbob" || language === "bob") { - return ; - } - if (language === "plantuml" || language === "puml") { - return ; - } - return ; - } - if (isBlock) { - return ; - } - return ( - - {children} - - ); - }, - img: ({ src, alt, ...props }) => { - return {alt}; - }, - a: ({ href, children, ...props }) => { - const resolved = resolveLink(href, fileId); - switch (resolved.type) { - case "external": - return ( - - {children} - - ); - case "hash": - return ( - - {children} - - ); - case "markdown": - return ( - handleLinkClick(e, resolved.hrefPath, resolved.anchor)} {...props}> - {children} - - ); - case "file": - return ( - - {children} - - ); - case "passthrough": + () => { + const headingRenderer = + (as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6") => + ({ node: _node, children, id, className, ...props }: React.HTMLAttributes & { node?: unknown }) => { + if (isSlidesView) { + const Tag = as; + return ( + + {children} + + ); + } + return ( - + toggleHeadingCollapse(id) : undefined} + {...props} + > {children} - + ); - } - }, - }), - [collapsedHeadingIds, fileId, handleLinkClick, toggleHeadingCollapse], + }; + + return { + pre: ({ children }) => <>{children}, + h1: headingRenderer("h1"), + h2: headingRenderer("h2"), + h3: headingRenderer("h3"), + h4: headingRenderer("h4"), + h5: headingRenderer("h5"), + h6: headingRenderer("h6"), + code: ({ className, children, ...props }) => { + const language = extractLanguage(className); + const code = String(children).replace(/\n$/, ""); + const isBlock = String(children).endsWith("\n"); + if (language) { + if (language === "mermaid") { + return ; + } + if (language === "svgbob" || language === "bob") { + return ; + } + if (language === "plantuml" || language === "puml") { + return ; + } + return ; + } + if (isBlock) { + return ; + } + return ( + + {children} + + ); + }, + img: ({ src, alt, ...props }) => { + return {alt}; + }, + a: ({ href, children, ...props }) => { + const resolved = resolveLink(href, fileId); + switch (resolved.type) { + case "external": + return ( + + {children} + + ); + case "hash": + return ( + + {children} + + ); + case "markdown": + return ( + handleLinkClick(e, resolved.hrefPath, resolved.anchor)} {...props}> + {children} + + ); + case "file": + return ( + + {children} + + ); + case "passthrough": + return ( + + {children} + + ); + } + }, + }; + }, + [collapsedHeadingIds, fileId, handleLinkClick, isSlidesView, toggleHeadingCollapse], ); const parsed = useMemo( @@ -1999,13 +2015,172 @@ export function MarkdownViewer({ [content, isRawView], ); + const transformedMarkdown = useMemo(() => { + if (isRawView) return ""; + const base = parsed ? parsed.content : content; + const normalized = fileName.endsWith(".mdx") ? stripMdxSyntax(base) : base; + return transformMarkdownForMo(normalized); + }, [content, fileName, isRawView, parsed]); + + const slides = useMemo( + () => (isRawView ? [] : splitMarkdownSlides(transformedMarkdown)), + [isRawView, transformedMarkdown], + ); + + useEffect(() => { + setSlideIndex((current) => { + if (slides.length === 0) return 0; + return Math.min(current, slides.length - 1); + }); + }, [slides.length]); + + const goPrevSlide = useCallback(() => { + setSlideIndex((current) => Math.max(0, current - 1)); + }, []); + + const goNextSlide = useCallback(() => { + setSlideIndex((current) => Math.min(Math.max(0, slides.length - 1), current + 1)); + }, [slides.length]); + + const goFirstSlide = useCallback(() => { + setSlideIndex(0); + }, []); + + const goLastSlide = useCallback(() => { + setSlideIndex(Math.max(0, slides.length - 1)); + }, [slides.length]); + + const handleSlidePageClick = useCallback( + (event: React.MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if (target.closest("a,button,input,textarea,select,label,summary,[role='button']")) { + return; + } + goNextSlide(); + }, + [goNextSlide], + ); + + const toggleSlidesFullscreen = useCallback(async () => { + const shell = slideShellRef.current; + if (!shell) return; + + try { + if (document.fullscreenElement === shell) { + await document.exitFullscreen?.(); + return; + } + await shell.requestFullscreen?.(); + } catch { + // Fullscreen may be blocked by browser policies + } + }, []); + + const clearSlidesOverlayTimer = useCallback(() => { + if (slidesOverlayTimerRef.current !== null) { + window.clearTimeout(slidesOverlayTimerRef.current); + slidesOverlayTimerRef.current = null; + } + }, []); + + const scheduleSlidesOverlayHide = useCallback(() => { + clearSlidesOverlayTimer(); + if (!isSlidesView || !isSlidesFullscreen || isSlidesOverlayPinned) return; + + slidesOverlayTimerRef.current = window.setTimeout(() => { + setIsSlidesOverlayVisible(false); + }, 2200); + }, [clearSlidesOverlayTimer, isSlidesFullscreen, isSlidesOverlayPinned, isSlidesView]); + + const revealSlidesOverlay = useCallback(() => { + if (!isSlidesView || !isSlidesFullscreen) return; + setIsSlidesOverlayVisible(true); + scheduleSlidesOverlayHide(); + }, [isSlidesFullscreen, isSlidesView, scheduleSlidesOverlayHide]); + + useEffect(() => { + const onFullscreenChange = () => { + setIsSlidesFullscreen(document.fullscreenElement === slideShellRef.current); + }; + + document.addEventListener("fullscreenchange", onFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", onFullscreenChange); + }; + }, []); + + useEffect(() => { + if (isSlidesView) return; + const shell = slideShellRef.current; + if (!shell || document.fullscreenElement !== shell) return; + void document.exitFullscreen?.(); + }, [isSlidesView]); + + useEffect(() => { + if (!isSlidesView || !isSlidesFullscreen) { + setIsSlidesOverlayVisible(true); + setIsSlidesOverlayPinned(false); + clearSlidesOverlayTimer(); + return; + } + + setIsSlidesOverlayVisible(true); + scheduleSlidesOverlayHide(); + + return () => { + clearSlidesOverlayTimer(); + }; + }, [clearSlidesOverlayTimer, isSlidesFullscreen, isSlidesView, scheduleSlidesOverlayHide]); + + const handleSlidesOverlayActivity = useCallback(() => { + revealSlidesOverlay(); + }, [revealSlidesOverlay]); + const renderedContent = useMemo(() => { if (isRawView) { return ; } - const base = parsed ? parsed.content : content; - const normalized = fileName.endsWith(".mdx") ? stripMdxSyntax(base) : base; - const md = transformMarkdownForMo(normalized); + + if (isSlidesView) { + const currentSlide = slides[slideIndex] ?? ""; + return ( +
+ +
+ + {currentSlide} + +
+ +
+ ); + } + return ( <> {parsed && } @@ -2014,15 +2189,30 @@ export function MarkdownViewer({ rehypePlugins={[rehypeRaw, rehypeGithubAlerts, rehypeSlug, rehypeKatex]} components={components} > - {md} + {transformedMarkdown} ); - }, [content, isRawView, parsed, components, fileName]); + }, [ + components, + content, + isRawView, + isSlidesFullscreen, + isSlidesOverlayPinned, + isSlidesOverlayVisible, + isSlidesView, + handleSlidesOverlayActivity, + handleSlidePageClick, + parsed, + slideIndex, + slides, + toggleSlidesFullscreen, + transformedMarkdown, + ]); useEffect(() => { const article = articleRef.current; - if (!article || loading || isRawView) return; + if (!article || loading || isRawView || isSlidesView) return; const resetHidden = () => { const hiddenEls = article.querySelectorAll("[data-heading-collapsed-hidden='1']"); @@ -2058,7 +2248,7 @@ export function MarkdownViewer({ return () => { resetHidden(); }; - }, [collapsedHeadingIds, isRawView, loading, renderedContent]); + }, [collapsedHeadingIds, isRawView, isSlidesView, loading, renderedContent]); const prevHeadingsKey = useRef(""); useEffect(() => { @@ -2096,10 +2286,11 @@ export function MarkdownViewer({ useEffect(() => { if (!searchJumpRequest) return; setIsRawView(false); + setIsSlidesView(false); }, [searchJumpRequest]); useEffect(() => { - if (!searchJumpRequest || loading || isRawView) return; + if (!searchJumpRequest || loading || isRawView || isSlidesView) return; let cancelled = false; let raf1 = 0; @@ -2136,7 +2327,116 @@ export function MarkdownViewer({ cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; - }, [isRawView, loading, onSearchJumpHandled, renderedContent, searchJumpRequest]); + }, [isRawView, isSlidesView, loading, onSearchJumpHandled, renderedContent, searchJumpRequest]); + + useEffect(() => { + if (!isSlidesView || loading) return; + + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + + if (isSlidesFullscreen) { + revealSlidesOverlay(); + } + + if (["ArrowRight", "PageDown", " ", "Enter"].includes(event.key)) { + event.preventDefault(); + goNextSlide(); + return; + } + if (["ArrowLeft", "PageUp"].includes(event.key)) { + event.preventDefault(); + goPrevSlide(); + return; + } + if (event.key === "Home") { + event.preventDefault(); + goFirstSlide(); + return; + } + if (event.key === "End") { + event.preventDefault(); + goLastSlide(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + if (document.fullscreenElement === slideShellRef.current) { + void document.exitFullscreen?.(); + return; + } + setIsSlidesView(false); + return; + } + if (event.key === "f" || event.key === "F") { + event.preventDefault(); + void toggleSlidesFullscreen(); + return; + } + if (event.key === "h" || event.key === "H") { + event.preventDefault(); + setIsSlidesOverlayPinned((current) => { + const next = !current; + if (next) { + clearSlidesOverlayTimer(); + setIsSlidesOverlayVisible(true); + } else { + scheduleSlidesOverlayHide(); + } + return next; + }); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [ + clearSlidesOverlayTimer, + goFirstSlide, + goLastSlide, + goNextSlide, + goPrevSlide, + isSlidesFullscreen, + isSlidesView, + loading, + revealSlidesOverlay, + scheduleSlidesOverlayHide, + toggleSlidesFullscreen, + ]); + + const handleRawToggle = useCallback(() => { + setIsRawView((current) => { + const next = !current; + if (next) setIsSlidesView(false); + return next; + }); + }, []); + + const handleSlidesToggle = useCallback(() => { + setIsSlidesView((current) => { + const next = !current; + if (next) { + setIsRawView(false); + setSlideIndex(0); + } + return next; + }); + }, []); + + const currentSlideLabel = slides.length > 0 ? Math.min(slideIndex + 1, slides.length) : 1; + const canPrevSlide = slideIndex > 0; + const canNextSlide = slideIndex < slides.length - 1; if (loading) { return ( @@ -2151,7 +2451,7 @@ export function MarkdownViewer({
{linkOpenError && (
)} + {isSlidesView && ( +
+ + PPT 模式 · 第 {currentSlideLabel}/{Math.max(slides.length, 1)} 页 · 快捷键 F 全屏 + +
+ + +
+
+ )} {renderedContent} - + {!isSlidesView && }
- setIsRawView((v) => !v)} /> + + diff --git a/frontend/src/components/SlidesToggle.test.tsx b/frontend/src/components/SlidesToggle.test.tsx new file mode 100644 index 0000000..1a6c998 --- /dev/null +++ b/frontend/src/components/SlidesToggle.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SlidesToggle } from "./SlidesToggle"; + +describe("SlidesToggle", () => { + it("shows 'Show slides' title when closed", () => { + render( { }} />); + expect(screen.getByTitle("Show slides")).toBeInTheDocument(); + }); + + it("shows 'Exit slides' title when open", () => { + render( { }} />); + expect(screen.getByTitle("Exit slides")).toBeInTheDocument(); + }); + + it("has aria-pressed false when closed", () => { + render( { }} />); + const button = screen.getByRole("button", { name: "Slides" }); + expect(button).toHaveAttribute("aria-pressed", "false"); + }); + + it("has aria-pressed true when open", () => { + render( { }} />); + const button = screen.getByRole("button", { name: "Slides" }); + expect(button).toHaveAttribute("aria-pressed", "true"); + }); + + it("calls onToggle when clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + + await user.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/SlidesToggle.tsx b/frontend/src/components/SlidesToggle.tsx new file mode 100644 index 0000000..039d955 --- /dev/null +++ b/frontend/src/components/SlidesToggle.tsx @@ -0,0 +1,30 @@ +interface SlidesToggleProps { + isSlidesOpen: boolean; + onToggle: () => void; +} + +export function SlidesToggle({ isSlidesOpen, onToggle }: SlidesToggleProps) { + return ( + + ); +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 0f14bbf..5b28dfe 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -43,6 +43,122 @@ body, max-width: none; } +.markdown-body.markdown-body--slides { + max-width: none; +} + +.markdown-slide-shell { + position: relative; + display: flex; + justify-content: center; + padding: 8px 0 16px; +} + +.markdown-slide-fullscreen-btn { + position: absolute; + right: 8px; + top: 8px; + z-index: 2; + border: 1px solid var(--color-gh-border); + border-radius: 8px; + background: color-mix(in oklab, var(--color-gh-bg-secondary) 92%, transparent); + color: var(--color-gh-text-secondary); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; + transition: opacity 0.18s ease; +} + +.markdown-slide-fullscreen-btn:hover { + background: var(--color-gh-bg-hover); +} + +.markdown-slide-help-badge { + position: absolute; + right: 10px; + bottom: 2px; + z-index: 2; + pointer-events: none; + border: 1px solid var(--color-gh-border); + border-radius: 999px; + background: color-mix(in oklab, var(--color-gh-bg-secondary) 92%, transparent); + color: var(--color-gh-text-secondary); + font-size: 11px; + line-height: 1.3; + padding: 4px 10px; + transition: opacity 0.18s ease; +} + +.markdown-slide-page { + width: min(1120px, 100%); + min-height: min(72vh, 740px); + min-height: min(72dvh, 740px); + aspect-ratio: 16 / 9; + overflow: auto; + border: 1px solid var(--color-gh-border); + border-radius: 12px; + background: var(--color-gh-bg); + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08); + padding: clamp(20px, 3vw, 42px); +} + +.markdown-body--slides .markdown-slide-page h1 { + font-size: clamp(2rem, 4vw, 3.2rem); +} + +.markdown-body--slides .markdown-slide-page h2 { + font-size: clamp(1.5rem, 2.8vw, 2.4rem); +} + +.markdown-body--slides .markdown-slide-page h3 { + font-size: clamp(1.2rem, 2.2vw, 1.8rem); +} + +.markdown-body--slides .markdown-slide-page p, +.markdown-body--slides .markdown-slide-page li { + font-size: clamp(1rem, 1.35vw, 1.25rem); + line-height: 1.6; +} + +[data-theme="dark"] .markdown-slide-page { + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35); +} + +.markdown-slide-shell:fullscreen { + width: 100%; + height: 100%; + padding: min(2.4vw, 20px); + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at top, #1f2937 0%, #0b1020 62%); +} + +.markdown-slide-shell:fullscreen .markdown-slide-page { + width: min(96vw, 1600px); + height: min(96vh, 920px); + max-height: none; + border-radius: 14px; +} + +.markdown-slide-shell:fullscreen .markdown-slide-help-badge { + bottom: min(2.4vh, 20px); + right: min(2.4vw, 24px); +} + +.markdown-slide-shell--overlay-hidden:fullscreen .markdown-slide-fullscreen-btn, +.markdown-slide-shell--overlay-hidden:fullscreen .markdown-slide-help-badge { + opacity: 0; +} + +.markdown-slide-shell--overlay-hidden:fullscreen .markdown-slide-fullscreen-btn { + pointer-events: none; +} + +.markdown-slide-shell:fullscreen::backdrop { + background: #0b1020; +} + .markdown-body .mermaid-block .mermaid-render svg { max-width: 100%; height: auto; diff --git a/internal/static/static.go b/internal/static/static.go index 5b5cc8d..c87626b 100644 --- a/internal/static/static.go +++ b/internal/static/static.go @@ -2,7 +2,7 @@ package static import "embed" -//go:generate sh -c "cd ../../frontend && pnpm install && pnpm run build" +//go:generate sh -c "cd ../../frontend && sh scripts/pnpm-install-safe.sh && pnpm run build" //go:embed all:dist var Frontend embed.FS