diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..e733076ec 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -9,16 +9,16 @@ vi.mock("@tauri-apps/api/core", () => ({ })); vi.mock("./ScoreViewer", () => ({ - ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => ( + ScoreViewer: ({ scorePdfBytes, fileName }: { scorePdfBytes: Uint8Array | null; fileName?: string }) => (
- {data ? `bytes:${data.length}` : "no-data"} + {scorePdfBytes ? `bytes:${scorePdfBytes.length}` : "no-data"} {fileName ? `:${fileName}` : ""}
) })); vi.mock("../../i18n", () => ({ - createTranslator: () => (key: string) => + createTranslator: () => (translationKey: string) => ({ scoreViewTitle: "Score", scoreViewSubtitle: "Attach validated PDF scores to the current song.", @@ -34,7 +34,7 @@ vi.mock("../../i18n", () => ({ scoreReadFailed: "Could not open the score PDF.", scoreRemoveFailed: "Could not remove the score PDF.", scoreRequiresProject: "Scores attach to the active analysis project." - })[key] ?? key, + })[translationKey] ?? translationKey, detectPreferredLocale: () => "en" })); @@ -413,4 +413,4 @@ describe("ScoreView", () => { }); expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data"); }); -}); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 72732450f..146ba2da9 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -26,10 +26,15 @@ export interface ScoreViewProps { * Extract the first line of a bridge error for display, falling back to the * provided message when the error carries no usable text. */ -function bridgeErrorDetail(error: unknown, fallback: string): string { - const raw = error instanceof Error ? error.message : typeof error === "string" ? error : null; - const firstLine = raw?.split(/\r?\n/)[0]?.trim(); - return firstLine ? firstLine : fallback; +function bridgeErrorDetail(bridgeError: unknown, fallbackMessage: string): string { + const rawErrorMessage = + bridgeError instanceof Error + ? bridgeError.message + : typeof bridgeError === "string" + ? bridgeError + : null; + const firstErrorLine = rawErrorMessage?.split(/\r?\n/)[0]?.trim(); + return firstErrorLine ? firstErrorLine : fallbackMessage; } /** @@ -38,13 +43,18 @@ function bridgeErrorDetail(error: unknown, fallback: string): string { * embedded viewer, and removes attachments (metadata plus stored copy). */ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]); - const [selected, setSelected] = useState(null); + const scoreTranslator = useMemo(() => createTranslator(detectPreferredLocale()), []); + const scoreAttachments = useMemo( + () => song.scoreAttachments ?? [], + [song.scoreAttachments] + ); + const [selectedScoreAttachment, setSelectedScoreAttachment] = useState( + null + ); const [pdfBytes, setPdfBytes] = useState(null); const [isAttaching, setIsAttaching] = useState(false); const [isOpening, setIsOpening] = useState(false); - const [error, setError] = useState(null); + const [scoreError, setScoreError] = useState(null); const readRequestRef = useRef(0); /** @@ -52,22 +62,27 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { * the active project id explicitly; the storage controls are only wired up * (and enabled) when a workspace is present, so this never runs without one. */ - const openAttachment = async (activeProjectId: string, attachment: ScoreAttachment) => { + const openAttachment = async ( + activeProjectId: string, + scoreAttachment: ScoreAttachment + ) => { const requestId = readRequestRef.current + 1; readRequestRef.current = requestId; - setSelected(attachment); + setSelectedScoreAttachment(scoreAttachment); setPdfBytes(null); - setError(null); + setScoreError(null); setIsOpening(true); try { - const bytes = await readScorePdf(activeProjectId, attachment.id); + const scorePdfBytes = await readScorePdf(activeProjectId, scoreAttachment.id); if (readRequestRef.current === requestId) { - setPdfBytes(bytes); + setPdfBytes(scorePdfBytes); } } catch (readError) { if (readRequestRef.current === requestId) { - setSelected(null); - setError(`${t("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim()); + setSelectedScoreAttachment(null); + setScoreError( + `${scoreTranslator("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim() + ); } } finally { if (readRequestRef.current === requestId) { @@ -82,56 +97,66 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { * started; the active project id is supplied by the enabled control. */ const handleAttach = async (activeProjectId: string) => { - setError(null); + setScoreError(null); setIsAttaching(true); try { - const result = await attachScorePdf(activeProjectId, song.id); - const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName }; - onSongUpdate({ ...song, scoreAttachments: [...attachments, attachment] }); + const attachmentResult = await attachScorePdf(activeProjectId, song.id); + const scoreAttachment: ScoreAttachment = { + id: attachmentResult.id, + fileName: attachmentResult.fileName + }; + onSongUpdate({ ...song, scoreAttachments: [...scoreAttachments, scoreAttachment] }); setIsAttaching(false); - await openAttachment(activeProjectId, attachment); + await openAttachment(activeProjectId, scoreAttachment); } catch (attachError) { setIsAttaching(false); - setError(bridgeErrorDetail(attachError, t("scoreAttachFailed"))); + setScoreError(bridgeErrorDetail(attachError, scoreTranslator("scoreAttachFailed"))); } }; /** Remove an attachment after confirmation (metadata and stored copy). */ - const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => { - const confirmed = window.confirm( - t("scoreRemoveConfirm").replace("{fileName}", attachment.fileName) + const handleRemove = async ( + activeProjectId: string, + scoreAttachment: ScoreAttachment + ) => { + const removalConfirmed = window.confirm( + scoreTranslator("scoreRemoveConfirm").replace("{fileName}", scoreAttachment.fileName) ); - if (!confirmed) { + if (!removalConfirmed) { return; } - setError(null); + setScoreError(null); try { - await removeScorePdf(activeProjectId, attachment.id); + await removeScorePdf(activeProjectId, scoreAttachment.id); onSongUpdate({ ...song, - scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id) + scoreAttachments: scoreAttachments.filter( + (scoreAttachmentEntry) => scoreAttachmentEntry.id !== scoreAttachment.id + ) }); - if (selected?.id === attachment.id) { + if (selectedScoreAttachment?.id === scoreAttachment.id) { readRequestRef.current += 1; - setSelected(null); + setSelectedScoreAttachment(null); setPdfBytes(null); setIsOpening(false); } } catch (removeError) { - setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed"))); + setScoreError(bridgeErrorDetail(removeError, scoreTranslator("scoreRemoveFailed"))); } }; return ( -
+

- {t("scoreViewTitle")} · {song.title} + {scoreTranslator("scoreViewTitle")} · {song.title}

-

{t("scoreViewSubtitle")}

+

+ {scoreTranslator("scoreViewSubtitle")} +

{!projectId && (

- {t("scoreRequiresProject")} + {scoreTranslator("scoreRequiresProject")}

)} - {error && ( + {scoreError && (

- {error} + {scoreError}

)}

- {t("scoreListTitle")} + {scoreTranslator("scoreListTitle")}

- {attachments.length === 0 ? ( -

{t("scoreListEmpty")}

+ {scoreAttachments.length === 0 ? ( +

{scoreTranslator("scoreListEmpty")}

) : (
    - {attachments.map((attachment) => ( + {scoreAttachments.map((scoreAttachment) => (
); diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd605..68f58825d 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -9,7 +9,7 @@ vi.mock("./pdfjs", () => ({ })); vi.mock("../../i18n", () => ({ - createTranslator: () => (key: string) => + createTranslator: () => (translationKey: string) => ({ scoreViewerEmpty: "No score PDF attached. Attach a validated score PDF to view it here.", scoreViewerLoading: "Loading score PDF...", @@ -21,7 +21,7 @@ vi.mock("../../i18n", () => ({ scoreViewerZoomIn: "Zoom in", scoreViewerZoomOut: "Zoom out", scoreViewerFitWidth: "Fit width" - })[key] ?? key, + })[translationKey] ?? translationKey, detectPreferredLocale: () => "en" })); @@ -53,26 +53,26 @@ function createFakePage(renderPromise: Promise = Promise.resolve()) { }; } -function createFakeDocument(numPages = 3, page = createFakePage()) { +function createFakeDocument(pageCount = 3, pdfPage = createFakePage()) { return { - page, - doc: { - numPages, - getPage: vi.fn(() => Promise.resolve(page)) + pdfPage, + pdfDocument: { + numPages: pageCount, + getPage: vi.fn(() => Promise.resolve(pdfPage)) } as unknown as PDFDocumentProxy }; } function mockLoadTaskOnce( - promise: Promise, - destroy: () => Promise = () => Promise.resolve() + loadingPromise: Promise, + destroyCallback: () => Promise = () => Promise.resolve() ) { - const destroyMock = vi.fn(destroy); + const destroyMock = vi.fn(destroyCallback); vi.mocked(loadScorePdf).mockReturnValueOnce({ - promise, + promise: loadingPromise, destroy: destroyMock } as unknown as PDFDocumentLoadingTask); - return { destroy: destroyMock }; + return { destroyMock }; } const SAMPLE_BYTES = new Uint8Array([0x25, 0x50, 0x44, 0x46]); @@ -86,9 +86,9 @@ describe("ScoreViewer", () => { vi.unstubAllGlobals(); }); - it("renders the empty placeholder without loading when no data is attached", () => { + it("renders the empty placeholder without loading when no score PDF bytes are attached", () => { const onStatusChange = vi.fn(); - render(); + render(); expect( screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.") @@ -100,10 +100,10 @@ describe("ScoreViewer", () => { it("transitions from LOADING to READY and renders the first page", async () => { const deferred = createDeferred(); mockLoadTaskOnce(deferred.promise); - const { doc, page } = createFakeDocument(3); + const { pdfDocument, pdfPage } = createFakeDocument(3); const onStatusChange = vi.fn(); - render(); + render(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading score PDF...")).toBeInTheDocument(); @@ -111,35 +111,35 @@ describe("ScoreViewer", () => { expect(loadScorePdf).toHaveBeenCalledWith(SAMPLE_BYTES); await act(async () => { - deferred.resolve(doc); + deferred.resolve(pdfDocument); }); expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); expect(onStatusChange).toHaveBeenLastCalledWith("READY"); await waitFor(() => { - expect(page.render).toHaveBeenCalled(); + expect(pdfPage.render).toHaveBeenCalled(); }); - expect(page.getViewport).toHaveBeenCalledWith({ scale: 1 }); + expect(pdfPage.getViewport).toHaveBeenCalledWith({ scale: 1 }); expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); }); it("shows the file name when provided", async () => { - const { doc } = createFakeDocument(1); - mockLoadTaskOnce(Promise.resolve(doc)); + const { pdfDocument } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("setlist-opener.pdf")).toBeInTheDocument(); }); it("transitions to FAILED with the error message and recovers on retry", async () => { mockLoadTaskOnce(Promise.reject(new Error("broken bytes"))); - const { doc } = createFakeDocument(2); - mockLoadTaskOnce(Promise.resolve(doc)); + const { pdfDocument } = createFakeDocument(2); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); const onStatusChange = vi.fn(); - render(); + render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.getByText("Could not display the score")).toBeInTheDocument(); @@ -159,17 +159,17 @@ describe("ScoreViewer", () => { it("stringifies non-Error load failures", async () => { mockLoadTaskOnce(Promise.reject("password protected")); - render(); + render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.getByText("password protected")).toBeInTheDocument(); }); it("navigates pages and clamps at both bounds", async () => { - const { doc } = createFakeDocument(3); - mockLoadTaskOnce(Promise.resolve(doc)); + const { pdfDocument } = createFakeDocument(3); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); const previousButton = screen.getByRole("button", { name: "Previous page" }); @@ -184,21 +184,21 @@ describe("ScoreViewer", () => { expect(nextButton).toBeDisabled(); await waitFor(() => { - expect(doc.getPage).toHaveBeenCalledWith(3); + expect(pdfDocument.getPage).toHaveBeenCalledWith(3); }); fireEvent.click(previousButton); expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); await waitFor(() => { - expect(doc.getPage).toHaveBeenCalledWith(2); + expect(pdfDocument.getPage).toHaveBeenCalledWith(2); }); }); it("zooms in and out with clamping and returns to fit-width", async () => { - const { doc, page } = createFakeDocument(1); - mockLoadTaskOnce(Promise.resolve(doc)); + const { pdfDocument, pdfPage } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); const zoomInButton = screen.getByRole("button", { name: "Zoom in" }); @@ -209,21 +209,21 @@ describe("ScoreViewer", () => { fireEvent.click(zoomInButton); expect(fitWidthButton).toHaveAttribute("aria-pressed", "false"); await waitFor(() => { - expect(page.getViewport).toHaveBeenCalledWith({ scale: 1.25 }); + expect(pdfPage.getViewport).toHaveBeenCalledWith({ scale: 1.25 }); }); - for (let clicks = 0; clicks < 8; clicks += 1) { + for (let zoomClickCount = 0; zoomClickCount < 8; zoomClickCount += 1) { fireEvent.click(zoomInButton); } await waitFor(() => { - expect(page.getViewport).toHaveBeenCalledWith({ scale: 4 }); + expect(pdfPage.getViewport).toHaveBeenCalledWith({ scale: 4 }); }); - for (let clicks = 0; clicks < 12; clicks += 1) { + for (let zoomClickCount = 0; zoomClickCount < 12; zoomClickCount += 1) { fireEvent.click(zoomOutButton); } await waitFor(() => { - expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + expect(pdfPage.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); }); fireEvent.click(fitWidthButton); @@ -233,8 +233,8 @@ describe("ScoreViewer", () => { it("re-renders at fit-width scale when the container resizes", async () => { let resizeCallback: ResizeObserverCallback | null = null; class FakeResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeCallback = callback; + constructor(resizeObserverCallback: ResizeObserverCallback) { + resizeCallback = resizeObserverCallback; } observe() {} unobserve() {} @@ -242,10 +242,10 @@ describe("ScoreViewer", () => { } vi.stubGlobal("ResizeObserver", FakeResizeObserver); - const { doc, page } = createFakeDocument(1); - mockLoadTaskOnce(Promise.resolve(doc)); + const { pdfDocument, pdfPage } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); @@ -267,59 +267,59 @@ describe("ScoreViewer", () => { }); await waitFor(() => { - expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + expect(pdfPage.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); }); }); it("keeps the READY layout when a page render is cancelled mid-flight", async () => { const renderFailure = Promise.reject(new Error("Rendering cancelled")); renderFailure.catch(() => undefined); - const page = createFakePage(renderFailure); - const { doc } = createFakeDocument(1, page); - mockLoadTaskOnce(Promise.resolve(doc)); + const pdfPage = createFakePage(renderFailure); + const { pdfDocument } = createFakeDocument(1, pdfPage); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); await waitFor(() => { - expect(page.render).toHaveBeenCalled(); + expect(pdfPage.render).toHaveBeenCalled(); }); expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); }); it("keeps the READY layout when fetching a page fails after load", async () => { - const doc = { + const pdfDocument = { numPages: 1, getPage: vi.fn(() => Promise.reject(new Error("destroyed"))) } as unknown as PDFDocumentProxy; - mockLoadTaskOnce(Promise.resolve(doc)); + mockLoadTaskOnce(Promise.resolve(pdfDocument)); - render(); + render(); expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); await waitFor(() => { - expect(doc.getPage).toHaveBeenCalled(); + expect(pdfDocument.getPage).toHaveBeenCalled(); }); expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); }); it("destroys the loading task on unmount and ignores late results", async () => { const deferred = createDeferred(); - const { destroy } = mockLoadTaskOnce(deferred.promise, () => + const { destroyMock } = mockLoadTaskOnce(deferred.promise, () => Promise.reject(new Error("already destroyed")) ); const onStatusChange = vi.fn(); const { unmount } = render( - + ); unmount(); - expect(destroy).toHaveBeenCalledTimes(1); + expect(destroyMock).toHaveBeenCalledTimes(1); - const { doc } = createFakeDocument(1); + const { pdfDocument } = createFakeDocument(1); await act(async () => { - deferred.resolve(doc); + deferred.resolve(pdfDocument); }); expect(onStatusChange).not.toHaveBeenCalledWith("READY"); }); @@ -330,7 +330,7 @@ describe("ScoreViewer", () => { const onStatusChange = vi.fn(); const { unmount } = render( - + ); unmount(); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx index 82692469e..82a81a42a 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -27,11 +27,11 @@ export interface ScoreViewerProps { * loads arbitrary URLs; callers (PR3 wires Tauri `read_score_pdf`) must * hand it bytes they already validated. */ - data: Uint8Array | null; + scorePdfBytes: Uint8Array | null; /** Optional display name of the attached score file. */ fileName?: string; /** Optional observer notified on every LOADING/FAILED/READY transition. */ - onStatusChange?: (status: ScoreViewerStatus) => void; + onStatusChange?: (viewerStatus: ScoreViewerStatus) => void; } const ZOOM_STEP = 1.25; @@ -45,14 +45,14 @@ const MAX_ZOOM = 4; * error with retry, READY canvas) plus rehearsal-friendly page navigation * and zoom in/out/fit-width controls. */ -export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const [status, setStatus] = useState("LOADING"); +export function ScoreViewer({ scorePdfBytes, fileName, onStatusChange }: ScoreViewerProps) { + const scoreTranslator = useMemo(() => createTranslator(detectPreferredLocale()), []); + const [viewerStatus, setViewerStatus] = useState("LOADING"); const [errorMessage, setErrorMessage] = useState(null); const [pdfDocument, setPdfDocument] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [pageCount, setPageCount] = useState(0); - const [zoom, setZoom] = useState(1); + const [zoomScale, setZoomScale] = useState(1); const [fitWidth, setFitWidth] = useState(true); const [containerWidth, setContainerWidth] = useState(0); const [retryToken, setRetryToken] = useState(0); @@ -60,83 +60,87 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps const containerRef = useRef(null); useEffect(() => { - if (data !== null) { - onStatusChange?.(status); + if (scorePdfBytes !== null) { + onStatusChange?.(viewerStatus); } - }, [data, status, onStatusChange]); + }, [scorePdfBytes, viewerStatus, onStatusChange]); useEffect(() => { - if (data === null) { + if (scorePdfBytes === null) { return; } - let cancelled = false; - setStatus("LOADING"); + let pdfLoadCancelled = false; + setViewerStatus("LOADING"); setErrorMessage(null); setPdfDocument(null); - const loadingTask = loadScorePdf(data); + const loadingTask = loadScorePdf(scorePdfBytes); loadingTask.promise .then((loadedDocument) => { - if (cancelled) { + if (pdfLoadCancelled) { return; } setPdfDocument(loadedDocument); setPageCount(loadedDocument.numPages); setPageNumber(1); - setStatus("READY"); + setViewerStatus("READY"); }) .catch((error: unknown) => { - if (cancelled) { + if (pdfLoadCancelled) { return; } setErrorMessage(error instanceof Error ? error.message : String(error)); - setStatus("FAILED"); + setViewerStatus("FAILED"); }); return () => { - cancelled = true; + pdfLoadCancelled = true; void loadingTask.destroy().catch(() => undefined); }; - }, [data, retryToken]); + }, [scorePdfBytes, retryToken]); useEffect(() => { - const container = containerRef.current; - if (status !== "READY" || !container || typeof ResizeObserver === "undefined") { + const viewerContainer = containerRef.current; + if ( + viewerStatus !== "READY" || + !viewerContainer || + typeof ResizeObserver === "undefined" + ) { return; } - const observer = new ResizeObserver((entries) => { + const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { setContainerWidth(entry.contentRect.width); } }); - observer.observe(container); - return () => observer.disconnect(); - }, [status]); + resizeObserver.observe(viewerContainer); + return () => resizeObserver.disconnect(); + }, [viewerStatus]); useEffect(() => { - const canvas = canvasRef.current; - if (status !== "READY" || !pdfDocument || !canvas) { + const scoreCanvas = canvasRef.current; + if (viewerStatus !== "READY" || !pdfDocument || !scoreCanvas) { return; } - let cancelled = false; + let pageRenderCancelled = false; let renderTask: RenderTask | null = null; pdfDocument .getPage(pageNumber) - .then((page) => { - if (cancelled) { + .then((pdfPage) => { + if (pageRenderCancelled) { return; } - const baseViewport = page.getViewport({ scale: 1 }); - const scale = - fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoom; - const viewport = page.getViewport({ scale }); - canvas.width = Math.floor(viewport.width); - canvas.height = Math.floor(viewport.height); - renderTask = page.render({ canvas, viewport }); + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const viewportScale = + fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoomScale; + const pageViewport = pdfPage.getViewport({ scale: viewportScale }); + scoreCanvas.width = Math.floor(pageViewport.width); + scoreCanvas.height = Math.floor(pageViewport.height); + renderTask = pdfPage.render({ canvas: scoreCanvas, viewport: pageViewport }); renderTask.promise.catch(() => { // Cancelled renders (rapid page/zoom changes) are expected. }); @@ -146,10 +150,10 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps }); return () => { - cancelled = true; + pageRenderCancelled = true; renderTask?.cancel(); }; - }, [status, pdfDocument, pageNumber, zoom, fitWidth, containerWidth]); + }, [viewerStatus, pdfDocument, pageNumber, zoomScale, fitWidth, containerWidth]); /** Move to the previous page, clamped at the first page. */ const goToPreviousPage = () => { @@ -164,13 +168,13 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps /** Switch to manual zoom and enlarge, clamped at the maximum scale. */ const zoomIn = () => { setFitWidth(false); - setZoom((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); + setZoomScale((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); }; /** Switch to manual zoom and shrink, clamped at the minimum scale. */ const zoomOut = () => { setFitWidth(false); - setZoom((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); + setZoomScale((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); }; /** Re-enable fit-width so the page tracks the container size. */ @@ -179,24 +183,24 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps }; /** Re-run the load state machine with the same validated bytes. */ - const retry = () => { + const retryPdfLoad = () => { setRetryToken((current) => current + 1); }; - if (data === null) { + if (scorePdfBytes === null) { return (
-

{t("scoreViewerEmpty")}

+

{scoreTranslator("scoreViewerEmpty")}

); } - if (status === "LOADING") { + if (viewerStatus === "LOADING") { return ( ); } - if (status === "FAILED") { + if (viewerStatus === "FAILED") { return ( ); } - const pageIndicator = t("scoreViewerPageIndicator") + const pageIndicator = scoreTranslator("scoreViewerPageIndicator") .replace("{current}", String(pageNumber)) .replace("{total}", String(pageCount)); @@ -257,7 +263,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps variant="outline" size="icon-lg" className="size-12" - aria-label={t("scoreViewerZoomOut")} + aria-label={scoreTranslator("scoreViewerZoomOut")} onClick={zoomOut} >