mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(documents): open a PDF or image original in a new browser tab
The document sheet gains an Open in new tab action for the types the browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report gets the browser's own zoom and search instead of the small preview. It opens the object URL the sheet already fetched through the app transport: a plain navigation to the serve route carries no record-session assertion and would be refused inside a shared record. Every other type keeps the download only, and the serve route's matrix now pins SVG and HTML to attachment as well.
This commit is contained in:
1 parent
fca1260b20
commit
12a8ab7c63
10 files changed
+95
-2
No files matched your search
@@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)",
|
||||
["image/heic", "photo.heic"],
|
||||
["application/xml", "export.xml"],
|
||||
["application/json", "export.json"],
|
||||
// Upload refuses markup outright; a row that somehow carries one must
|
||||
// still never render in-origin, where its script would run.
|
||||
["image/svg+xml", "drawing.svg"],
|
||||
["text/html", "page.html"],
|
||||
])(
|
||||
"Class B %s → attachment + octet-stream, never inline",
|
||||
async (mime, name) => {
|
||||
|
||||
@@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open in new tab" hands the browser the types it renders itself, so a long
|
||||
* report gets the browser's zoom and search. It must open the object URL the
|
||||
* sheet already holds (a bare navigation to the route carries no record-session
|
||||
* assertion and is refused once the session has been inside a shared record),
|
||||
* and it must never appear for a type outside the inline serving class.
|
||||
*/
|
||||
describe("the detail sheet's open-in-new-tab control", () => {
|
||||
const control = SHEET.slice(
|
||||
SHEET.indexOf("function DocumentOpenInTabButton("),
|
||||
SHEET.indexOf("/** Inline preview for Class A documents"),
|
||||
);
|
||||
|
||||
it("exists", () => {
|
||||
expect(control.length).toBeGreaterThan(0);
|
||||
expect(SHEET).toMatch(/<DocumentOpenInTabButton\s/);
|
||||
});
|
||||
|
||||
it("renders only for the inline serving class", () => {
|
||||
expect(SHEET).toMatch(
|
||||
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
|
||||
);
|
||||
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens the fetched object URL, never the route, without an opener", () => {
|
||||
expect(control).toContain('window.open(readyUrl, "_blank", "noopener")');
|
||||
expect(control).not.toContain("/api/documents");
|
||||
});
|
||||
|
||||
it("is disabled until the bytes are in hand, and when they failed", () => {
|
||||
expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/);
|
||||
expect(SHEET).toMatch(
|
||||
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the stored original in a new browser tab, for the types the browser
|
||||
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
|
||||
* long report gets the browser's own zoom and search.
|
||||
*
|
||||
* It opens the object URL the sheet already holds rather than the route: a
|
||||
* plain navigation carries no record-session assertion, so the fence would
|
||||
* refuse it once the session has been inside a shared record. The bytes are
|
||||
* already in hand, so the tab opens inside the click and no popup blocker
|
||||
* steps in. A type outside the inline class never gets the control.
|
||||
*/
|
||||
function DocumentOpenInTabButton({
|
||||
readyUrl,
|
||||
unavailable,
|
||||
}: {
|
||||
readyUrl: string | null;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
data-slot="document-open-in-tab"
|
||||
disabled={readyUrl === null || unavailable}
|
||||
aria-label={t("documents.detail.openInNewTab")}
|
||||
onClick={() => {
|
||||
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.openInNewTab")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline preview for Class A documents; skeleton until the blob paints. */
|
||||
function InlinePreview({
|
||||
src,
|
||||
@@ -759,8 +797,8 @@ export function DocumentDetailSheet({
|
||||
doc ? (
|
||||
// Delete sits bottom-LEFT, quieted to a text button: the
|
||||
// bottom-right slot reads as the primary/confirm action, and a
|
||||
// destructive control there is a footgun. Share + Download — the
|
||||
// safe, expected actions — keep the trailing edge. On phones their
|
||||
// destructive control there is a footgun. Share, open in a new tab and
|
||||
// Download, the safe, expected actions, keep the trailing edge. On phones their
|
||||
// labels collapse to icon-only (aria-labelled) so three controls
|
||||
// never overflow the sheet at 360 px; the labels return at `sm+`.
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{doc.servingClass === "inline" ? (
|
||||
<DocumentOpenInTabButton
|
||||
readyUrl={inlineSrc}
|
||||
unavailable={inlineFailed}
|
||||
/>
|
||||
) : null}
|
||||
<DocumentDownloadButton
|
||||
documentId={doc.id}
|
||||
filename={doc.filename}
|
||||
|
||||
Reference in new issue
Block a user