is suspended while getPage() is
+ // pending). The previous implementation early-returned in that case
+ // and never recovered because `[ref]` deps are stable, leaving rect
+ // null forever and PointerOverlay perpetually rendering null.
+ // Watch the document for the ref's element to attach instead.
+ let cleanup: (() => void) | null = null;
+
+ const setupOn = (el: HTMLElement) => {
+ const update = () => setRect(el.getBoundingClientRect());
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(el);
+ window.addEventListener("resize", update);
+ window.addEventListener("scroll", update, true);
+ cleanup = () => {
+ observer.disconnect();
+ window.removeEventListener("resize", update);
+ window.removeEventListener("scroll", update, true);
+ };
+ };
+
+ const initial = ref.current;
+ if (initial) {
+ setupOn(initial);
+ return () => cleanup?.();
+ }
+
+ const mo = new MutationObserver(() => {
+ const el = ref.current;
+ if (el) {
+ mo.disconnect();
+ setupOn(el);
+ }
+ });
+ mo.observe(document.body, { childList: true, subtree: true });
return () => {
- observer.disconnect();
- window.removeEventListener("resize", update);
- window.removeEventListener("scroll", update, true);
+ mo.disconnect();
+ cleanup?.();
};
}, [ref]);
return rect;
diff --git a/src/lib/thumbnail.test.ts b/src/lib/thumbnail.test.ts
index dbcbe68..2e0b4fd 100644
--- a/src/lib/thumbnail.test.ts
+++ b/src/lib/thumbnail.test.ts
@@ -1,74 +1,40 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-vi.mock("pdfjs-dist/build/pdf.worker.min.mjs?url", () => ({
- default: "mock-worker.js",
-}));
-vi.mock("pdfjs-dist", () => ({
- getDocument: vi.fn(),
- GlobalWorkerOptions: { workerSrc: "" },
-}));
-
-import * as pdfjs from "pdfjs-dist";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import demoPdfUrl from "../../demo/pdfpw-demo.pdf?url";
import { generateThumbnail } from "./thumbnail.ts";
-describe("generateThumbnail", () => {
- const mockGetDocument = vi.mocked(pdfjs.getDocument);
-
- beforeEach(() => {
- const mockRender = vi.fn(() => ({ promise: Promise.resolve() }));
- const mockGetViewport = vi.fn((opts?: { scale?: number }) => ({
- width: 800 * (opts?.scale ?? 1),
- height: 600 * (opts?.scale ?? 1),
- }));
- const mockPage = {
- getViewport: mockGetViewport,
- render: mockRender,
- };
- const mockPdfProxy = {
- getPage: vi.fn(() => Promise.resolve(mockPage)),
- destroy: vi.fn(() => Promise.resolve()),
- };
- mockGetDocument.mockReturnValue({
- promise: Promise.resolve(mockPdfProxy),
- // biome-ignore lint/suspicious/noExplicitAny: テスト用モック
- } as any);
- });
+async function loadDemoPdfFile(): Promise
{
+ const res = await fetch(demoPdfUrl);
+ const blob = await res.blob();
+ return new File([blob], "pdfpw-demo.pdf", { type: "application/pdf" });
+}
+describe("generateThumbnail", () => {
afterEach(() => {
vi.restoreAllMocks();
});
- it("PDFの1ページ目のJPEG data URLを返す", async () => {
- const file = new File(["pdf"], "test.pdf", { type: "application/pdf" });
+ it("returns a JPEG data URL for a real PDF", async () => {
+ const file = await loadDemoPdfFile();
const result = await generateThumbnail(file);
expect(result).toMatch(/^data:image\/jpeg;base64,/);
- expect(mockGetDocument).toHaveBeenCalled();
- // biome-ignore lint/suspicious/noExplicitAny: テスト用モック
- const proxy = await (mockGetDocument.mock.results[0].value as any).promise;
- expect(proxy.getPage).toHaveBeenCalledWith(1);
+ // A rendered page produces a non-trivial JPEG (>1 KB encoded).
+ expect((result ?? "").length).toBeGreaterThan(1000);
});
- it("PDF ロードエラー時は null を返す", async () => {
- mockGetDocument.mockImplementation(
- () =>
- ({
- // Promise.reject を即時生成すると unhandled rejection になるため、lazy に生成する
- get promise() {
- return Promise.reject(new Error("load error"));
- },
- // biome-ignore lint/suspicious/noExplicitAny: テスト用モック
- }) as any,
- );
- const file = new File(["pdf"], "test.pdf", { type: "application/pdf" });
- const result = await generateThumbnail(file);
+ it("returns null for a corrupted PDF", async () => {
+ const broken = new File([new Uint8Array([0, 0, 0, 0])], "broken.pdf", {
+ type: "application/pdf",
+ });
+ const result = await generateThumbnail(broken);
expect(result).toBeNull();
});
- it("canvas の 2d context を取得できない場合は null を返す", async () => {
- // biome-ignore lint/suspicious/noExplicitAny: テスト用モック
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null as any);
-
- const file = new File(["pdf"], "test.pdf", { type: "application/pdf" });
+ it("returns null when canvas getContext returns null", async () => {
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+ // biome-ignore lint/suspicious/noExplicitAny: forcing the failure branch
+ null as any,
+ );
+ const file = await loadDemoPdfFile();
const result = await generateThumbnail(file);
expect(result).toBeNull();
});
diff --git a/src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx b/src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx
index 90ca290..6c21e5c 100644
--- a/src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx
+++ b/src/routes/$locale/(main)/-presenter/NextPrevFooter.tsx
@@ -5,6 +5,7 @@ import { PdfPage } from "#src/components/PdfPage.tsx";
import { Button } from "#src/components/ui/button.tsx";
import { Skeleton } from "#src/components/ui/skeleton.tsx";
import type { ResolvedPdfpcConfigV2 } from "#src/lib/pdfpc-config.ts";
+import * as m from "#src/paraglide/messages.js";
import { getNextSlidePageNumber } from "./NextSlide";
import { Timer, type TimerHandle } from "./Timer";
@@ -87,6 +88,7 @@ function NextPrevFooterCore({
variant="ghost"
size="icon-lg"
onClick={onPrevSlide}
+ aria-label={m.presenter_prev_slide_aria()}
className="rounded-full"
>
@@ -101,6 +103,7 @@ function NextPrevFooterCore({
size="icon-lg"
className="rounded-full"
onClick={onNextSlide}
+ aria-label={m.presenter_next_slide_aria()}
>
diff --git a/src/routes/$locale/(main)/-presenter/Timer.tsx b/src/routes/$locale/(main)/-presenter/Timer.tsx
index 94b1331..f20be36 100644
--- a/src/routes/$locale/(main)/-presenter/Timer.tsx
+++ b/src/routes/$locale/(main)/-presenter/Timer.tsx
@@ -9,6 +9,7 @@ import {
import { Button } from "#src/components/ui/button.tsx";
import type { ResolvedPdfpcConfigV2 } from "#src/lib/pdfpc-config";
import { cn } from "#src/lib/utils";
+import * as m from "#src/paraglide/messages.js";
import { getLocale } from "#src/paraglide/runtime.js";
export interface TimerHandle {
@@ -409,6 +410,7 @@ export const Timer = forwardRef(function Timer(
type="button"
variant={view.isRunning ? "secondary" : "default"}
size="icon-sm"
+ aria-label={m.presenter_timer_toggle_aria()}
onClick={() => dispatch({ type: "TOGGLE", nowMs: Date.now() })}
>
{view.isRunning ? : }
@@ -418,6 +420,7 @@ export const Timer = forwardRef(function Timer(
type="button"
variant="ghost"
size="icon-sm"
+ aria-label={m.presenter_timer_reset_aria()}
onClick={() =>
dispatch({
type: "RESET",
diff --git a/src/routes/$locale/(main)/index.tsx b/src/routes/$locale/(main)/index.tsx
index 78ecb72..5687ce0 100644
--- a/src/routes/$locale/(main)/index.tsx
+++ b/src/routes/$locale/(main)/index.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from "react";
import * as typia from "typia";
+import { ensurePresenterPairId } from "#src/broadcast";
import { TypstDiagnosticList } from "#src/components/TypstDiagnosticList";
import { useLocalStorageSync } from "#src/hooks/use-local-storage-sync";
import { FetchPdfError, fetchPdfFromUrl } from "#src/lib/fetch-pdf";
@@ -249,6 +250,13 @@ function Home() {
},
}).href;
+ // Seed the pair-id into sessionStorage BEFORE window.open so the popup
+ // inherits it at creation time. Without this, the presentation window
+ // has empty sessionStorage and falls back to lobby pairing, which can
+ // time out on slow environments before the presenter's broadcast hooks
+ // finish mounting (the presenter is suspended on PDF.js / pdfpc parsing).
+ ensurePresenterPairId(pdf.name);
+
if (presentationWindow && !presentationWindow.closed) {
presentationWindow.location.href = url;
presentationWindow.focus();
diff --git a/vite.config.ts b/vite.config.ts
index 095176f..3ffe97e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -9,7 +9,7 @@ import { devtools } from "@tanstack/devtools-vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { regex } from "arkregex";
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
import { VitePWA } from "vite-plugin-pwa";
const GITHUB_REPO_URL_REGEX = regex(
@@ -221,6 +221,7 @@ export default defineConfig({
test: {
globals: true,
setupFiles: ["./src/test-setup.ts"],
+ exclude: [...configDefaults.exclude, "e2e/**"],
browser: {
enabled: true,
provider: playwright(),