{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