From 8bed496fbbf4d444e5d9a58fdf0d1cc0be31020d Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:02:03 +0700 Subject: [PATCH] feat(viewers): full-screen reading for document, image & map viewers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a shared ExpandableViewer wrapper (built on the useExpand hook: native Fullscreen API + iOS CSS-overlay fallback, Esc to exit) and wrap 13 viewer tools with it so each can be read full-screen — handy on a phone: PPTX, Legacy .doc, GEDCOM, EML, DICOM, iWork, DOCX, Spreadsheet/ CSV, EPUB, ODT, Image, SVG and GeoJSON/GPX/KML viewers. Happy-path E2E asserts the toggle on two representative viewers; the registry render smoke covers all 13. --- e2e/tools/viewer-fullscreen.spec.ts | 21 ++++++++++++ src/components/ui/ExpandableViewer.tsx | 38 +++++++++++++++++++++ src/islands/documents/DicomViewer.tsx | 3 ++ src/islands/documents/DocViewer.tsx | 3 ++ src/islands/documents/DocxViewer.tsx | 3 ++ src/islands/documents/EmlViewer.tsx | 3 ++ src/islands/documents/EpubReader.tsx | 3 ++ src/islands/documents/GedcomViewer.tsx | 3 ++ src/islands/documents/IWorkViewer.tsx | 3 ++ src/islands/documents/OdtViewer.tsx | 3 ++ src/islands/documents/PptxViewer.tsx | 3 ++ src/islands/documents/SpreadsheetViewer.tsx | 3 ++ src/islands/image/ImageViewer.tsx | 3 ++ src/islands/image/SvgViewer.tsx | 3 ++ src/islands/maps/GeoViewer.tsx | 3 ++ 15 files changed, 98 insertions(+) create mode 100644 e2e/tools/viewer-fullscreen.spec.ts create mode 100644 src/components/ui/ExpandableViewer.tsx diff --git a/e2e/tools/viewer-fullscreen.spec.ts b/e2e/tools/viewer-fullscreen.spec.ts new file mode 100644 index 00000000..43bbed94 --- /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 00000000..a4d9cb1e --- /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 876c3753..64219993 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 95dc5735..8051fa3e 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 baa205e0..e83915ce 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 53c5722f..1b4a9fd2 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 08905217..d3c942df 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 d7b51b59..a38a8a2e 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 65b04cfe..07373e9d 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 1a68ab09..9fb3f966 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 b9e1e989..e17c0fdb 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 80718674..feed2229 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 1c81ba29..5aaf6b45 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 a2984af4..6c32eb24 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 b156d33a..56b70d01 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 }) {
)}
+
); }