diff --git a/e2e/tools/viewer-fullscreen.spec.ts b/e2e/tools/viewer-fullscreen.spec.ts new file mode 100644 index 0000000..43bbed9 --- /dev/null +++ b/e2e/tools/viewer-fullscreen.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; + +// The full-screen expand is a shared wrapper (ExpandableViewer + useExpand) +// added to the viewer tools. The button renders regardless of loaded content, +// so we can assert the toggle without opening a file. Native fullscreen may be +// denied in headless, but the CSS-overlay fallback still flips the label. +for (const route of ['/tools/svg-viewer', '/tools/image-viewer']) { + test(`full-screen toggle works on ${route}`, async ({ page }) => { + await page.goto(route); + await page.waitForLoadState('networkidle').catch(() => {}); + + const expand = page.getByRole('button', { name: 'Full screen' }); + await expect(async () => { + await expand.click(); + await expect(page.getByRole('button', { name: 'Exit' })).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30_000 }); + + await page.getByRole('button', { name: 'Exit' }).click(); + await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible(); + }); +} diff --git a/src/components/ui/ExpandableViewer.tsx b/src/components/ui/ExpandableViewer.tsx new file mode 100644 index 0000000..a4d9cb1 --- /dev/null +++ b/src/components/ui/ExpandableViewer.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import { Maximize2, Minimize2 } from 'lucide-react'; +import { useExpand } from '@/hooks/useExpand'; +import type { Lang } from '@/i18n/config'; + +const LBL: Record = { + en: { expand: 'Full screen', exit: 'Exit' }, + id: { expand: 'Layar penuh', exit: 'Keluar' }, +}; + +/** + * Wraps a viewer tool with a "Full screen" toggle. Reuses the shared useExpand + * hook (native Fullscreen API + iOS CSS-overlay fallback, Esc to exit) so any + * viewer can be read full-screen — handy on a phone. When expanded, the wrapper + * fills the viewport with an opaque background and scrolls its content. + */ +export function ExpandableViewer({ lang = 'en', children }: { lang?: Lang; children: ReactNode }) { + const { ref, expanded, enter, exit } = useExpand(); + const t = LBL[lang] ?? LBL.en; + return ( +
+
+ +
+ {children} +
+ ); +} + +export default ExpandableViewer; diff --git a/src/islands/documents/DicomViewer.tsx b/src/islands/documents/DicomViewer.tsx index 876c375..6421999 100644 --- a/src/islands/documents/DicomViewer.tsx +++ b/src/islands/documents/DicomViewer.tsx @@ -3,6 +3,7 @@ import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { applyWindowLevel, isUncompressed, rescale, parseFrameCount, clampFrameCount } from '@/tools/documents/dicom.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; interface Loaded { @@ -134,6 +135,7 @@ export default function DicomViewer({ lang = 'en' }: { lang?: Lang }) { }, [loaded, center, width, frame]); return ( +

{t.intro}

@@ -199,5 +201,6 @@ export default function DicomViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/DocViewer.tsx b/src/islands/documents/DocViewer.tsx index 95dc573..8051fa3 100644 --- a/src/islands/documents/DocViewer.tsx +++ b/src/islands/documents/DocViewer.tsx @@ -3,6 +3,7 @@ import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { CopyButton } from '@/components/ui/CopyButton'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import { downloadService } from '@/services/download'; import type { Lang } from '@/i18n/config'; @@ -55,6 +56,7 @@ export default function DocViewer({ lang = 'en' }: { lang?: Lang }) { const wordCount = text ? text.split(/\s+/).filter(Boolean).length : 0; return ( +

{t.intro}

@@ -83,5 +85,6 @@ export default function DocViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/DocxViewer.tsx b/src/islands/documents/DocxViewer.tsx index baa205e..e83915c 100644 --- a/src/islands/documents/DocxViewer.tsx +++ b/src/islands/documents/DocxViewer.tsx @@ -3,6 +3,7 @@ import { FileText, Printer } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record

{t.intro}

@@ -91,5 +93,6 @@ export default function DocxViewer({ lang = 'en' }: { lang?: Lang }) { className={`docx-viewer ${hasDoc ? 'max-h-[78vh] overflow-auto border-2 border-border bg-neutral-200 p-3 dark:bg-neutral-800 print:max-h-none print:overflow-visible print:border-0 print:bg-white print:p-0' : ''}`} />
+ ); } diff --git a/src/islands/documents/EmlViewer.tsx b/src/islands/documents/EmlViewer.tsx index 53c5722..1b4a9fd 100644 --- a/src/islands/documents/EmlViewer.tsx +++ b/src/islands/documents/EmlViewer.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import { formatAddress, formatAddressList } from '@/tools/documents/eml.lib'; import { downloadService } from '@/services/download'; import type { Lang } from '@/i18n/config'; @@ -68,6 +69,7 @@ export default function EmlViewer({ lang = 'en' }: { lang?: Lang }) { ) : null; return ( +

{t.intro}

@@ -117,5 +119,6 @@ export default function EmlViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/EpubReader.tsx b/src/islands/documents/EpubReader.tsx index 0890521..d3c942d 100644 --- a/src/islands/documents/EpubReader.tsx +++ b/src/islands/documents/EpubReader.tsx @@ -5,6 +5,7 @@ import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { flattenToc, type FlatTocItem } from '@/tools/documents/epub-toc.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const MIN_FONT = 70; @@ -133,6 +134,7 @@ export default function EpubReader({ lang = 'en' }: { lang?: Lang }) { }; return ( +

{t.intro}

@@ -202,5 +204,6 @@ export default function EpubReader({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/GedcomViewer.tsx b/src/islands/documents/GedcomViewer.tsx index d7b51b5..a38a8a2 100644 --- a/src/islands/documents/GedcomViewer.tsx +++ b/src/islands/documents/GedcomViewer.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import { parseGedcom, displayName, type Gedcom, type Individual } from '@/tools/documents/gedcom.lib'; import type { Lang } from '@/i18n/config'; @@ -74,6 +75,7 @@ export default function GedcomViewer({ lang = 'en' }: { lang?: Lang }) { ); return ( +

{t.intro}

@@ -132,5 +134,6 @@ export default function GedcomViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/IWorkViewer.tsx b/src/islands/documents/IWorkViewer.tsx index 65b04cf..07373e9 100644 --- a/src/islands/documents/IWorkViewer.tsx +++ b/src/islands/documents/IWorkViewer.tsx @@ -5,6 +5,7 @@ import { Alert } from '@/components/ui/Alert'; import { findPreview } from '@/tools/documents/iwork.lib'; import { readSlideOutline, type SlideOutline } from '@/tools/documents/iwa.lib'; import { openPdfRenderer, type PdfRenderer } from '@/tools/pdf/render.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record> = { @@ -147,6 +148,7 @@ export default function IWorkViewer({ lang = 'en' }: { lang?: Lang }) { const label = (s: SlideOutline, i: number) => s.title || s.body[0] || `${t.slide} ${i + 1}`; return ( +

{t.intro}

@@ -272,5 +274,6 @@ export default function IWorkViewer({ lang = 'en' }: { lang?: Lang }) { )}
+
); } diff --git a/src/islands/documents/OdtViewer.tsx b/src/islands/documents/OdtViewer.tsx index 1a68ab0..9fb3f96 100644 --- a/src/islands/documents/OdtViewer.tsx +++ b/src/islands/documents/OdtViewer.tsx @@ -3,6 +3,7 @@ import { FileType2, Printer } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record

{t.intro}

@@ -131,5 +133,6 @@ export default function OdtViewer({ lang = 'en' }: { lang?: Lang }) {
)} + ); } diff --git a/src/islands/documents/PptxViewer.tsx b/src/islands/documents/PptxViewer.tsx index b9e1e98..e17c0fd 100644 --- a/src/islands/documents/PptxViewer.tsx +++ b/src/islands/documents/PptxViewer.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { PptxDoc, Shape } from '@/tools/documents/pptx.lib'; import type { Lang } from '@/i18n/config'; @@ -112,6 +113,7 @@ export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) { }; return ( +

{t.intro}

@@ -153,5 +155,6 @@ export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/documents/SpreadsheetViewer.tsx b/src/islands/documents/SpreadsheetViewer.tsx index 8071867..feed222 100644 --- a/src/islands/documents/SpreadsheetViewer.tsx +++ b/src/islands/documents/SpreadsheetViewer.tsx @@ -4,6 +4,7 @@ import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { colLabel, readWorkbook, type SheetView } from '@/tools/documents/spreadsheet.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record Math.max(m, r.length), 0) ?? 0; return ( +

{t.intro}

@@ -128,5 +130,6 @@ export default function SpreadsheetViewer({ lang = 'en' }: { lang?: Lang }) {
)} +
); } diff --git a/src/islands/image/ImageViewer.tsx b/src/islands/image/ImageViewer.tsx index 1c81ba2..5aaf6b4 100644 --- a/src/islands/image/ImageViewer.tsx +++ b/src/islands/image/ImageViewer.tsx @@ -8,6 +8,7 @@ import { formatBytes } from '@/tools/image/canvas.lib'; import { parseIcoEntries, type IcoEntry } from '@/tools/image/ico.lib'; import { readExifSummary, type ExifSummary } from '@/tools/image/exif.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record
@@ -158,5 +160,6 @@ export default function ImageViewer({ lang = 'en' }: { lang?: Lang }) {
)}
+ ); } diff --git a/src/islands/image/SvgViewer.tsx b/src/islands/image/SvgViewer.tsx index a2984af..6c32eb2 100644 --- a/src/islands/image/SvgViewer.tsx +++ b/src/islands/image/SvgViewer.tsx @@ -6,6 +6,7 @@ import { Alert } from '@/components/ui/Alert'; import { ImageResult } from '@/components/ui/ImageResult'; import { ZoomPane } from '@/components/ui/ZoomPane'; import { parseSvgSize, rasterizeSvg } from '@/tools/image/svg.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const TR: Record
@@ -138,5 +140,6 @@ export default function SvgViewer({ lang = 'en' }: { lang?: Lang }) { {result && }
+ ); } diff --git a/src/islands/maps/GeoViewer.tsx b/src/islands/maps/GeoViewer.tsx index b156d33..56b70d0 100644 --- a/src/islands/maps/GeoViewer.tsx +++ b/src/islands/maps/GeoViewer.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { resolveStyle, MAP_STYLES, type StyleChoice } from '@/tools/geo/map-styles.lib'; import { parseGeoFile, computeBbox } from '@/tools/geo/geo-parse.lib'; +import { ExpandableViewer } from '@/components/ui/ExpandableViewer'; import type { Lang } from '@/i18n/config'; const STYLE_KEY = 'gwt.map.style'; @@ -128,6 +129,7 @@ export default function GeoViewer({ lang = 'en' }: { lang?: Lang }) { }; return ( +
@@ -166,5 +168,6 @@ export default function GeoViewer({ lang = 'en' }: { lang?: Lang }) {
)}
+
); }