diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml index 98dfebd1..a8930718 100644 --- a/.github/workflows/public-ci.yml +++ b/.github/workflows/public-ci.yml @@ -67,3 +67,6 @@ jobs: - name: Browser smoke run: pnpm verify:browser-smoke + + - name: Verify PPTX slideshow + run: pnpm verify:pptx-slideshow diff --git a/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs b/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs new file mode 100644 index 00000000..fded59a8 --- /dev/null +++ b/apps/viewer-demo/scripts/verify-pptx-slideshow.mjs @@ -0,0 +1,377 @@ +import { chromium } from 'playwright' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { extname, join, normalize, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Self-host the built demo so the script needs no external server and can run +// in CI. Build first: `pnpm build`, then `pnpm verify:pptx-slideshow`. +const DIST = fileURLToPath(new URL('../dist', import.meta.url)) + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.otf': 'font/otf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost') + let pathname = decodeURIComponent(url.pathname) + if (pathname === '/') { + pathname = '/index.html' + } + let filePath = join(DIST, normalize(pathname)) + if (!extname(pathname)) { + filePath = join(DIST, `${pathname}.html`) + } + if (!filePath.startsWith(DIST + sep)) { + res.writeHead(403) + res.end('forbidden') + return + } + let body + try { + body = await readFile(filePath) + } catch { + // SPA fallback: unknown routes render the app shell. + body = await readFile(join(DIST, 'index.html')) + } + res.writeHead(200, { + 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream', + 'Cache-Control': 'no-store', + }) + res.end(body) + } catch (error) { + res.writeHead(500) + res.end(String(error)) + } +}) + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +const { port } = server.address() +const BASE = `http://127.0.0.1:${port}` + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) +const errors = [] +page.on('pageerror', e => errors.push(String(e))) + +const deep = sel => page.locator(sel).first() + +// The viewer renders inside a shadow root, so plain document.querySelector misses everything. +const DEEP = ` + const deepAll = (selector, node = document, out = []) => { + out.push(...node.querySelectorAll(selector)); + for (const el of node.querySelectorAll('*')) { + if (el.shadowRoot) deepAll(selector, el.shadowRoot, out); + } + return out; + }; + const deepOne = (selector) => deepAll(selector)[0] || null; +` + +const readState = () => page.evaluate(`(() => { + ${DEEP} + const root = deepOne('.flyfish-pptx-presentation') + const content = deepOne('.flyfish-pptx-content') + const slots = Array.from(content?.querySelectorAll(':scope > .flyfish-pptx-slide-slot') || []) + const visible = slots.filter(s => getComputedStyle(s).display !== 'none') + return { + overlay: Boolean(root), + counter: root?.querySelector('.flyfish-pptx-presentation-counter')?.textContent || '', + visibleSlides: visible.length, + activeNumber: visible[0]?.dataset.slideNumber || '', + presenting: content?.classList.contains('is-presenting') || false, + transform: content instanceof HTMLElement ? content.style.transform : '', + inOverlay: Boolean(root && content && root.contains(content)), + } +})()`) + +// --------------------------------------------------------------------------- +// Part 1: the demo app (renderer wrapper integration) +// --------------------------------------------------------------------------- +await page.goto(`${BASE}/?url=${encodeURIComponent('/example/ppt.pptx')}`, { waitUntil: 'load' }) + +await deep('.flyfish-pptx-slide-slot').waitFor({ state: 'attached', timeout: 60_000 }) +await deep('.pptx-slideshow-button').waitFor({ state: 'visible', timeout: 60_000 }) +console.log('1) deck rendered, slideshow button visible') + +const slotCount = await page.locator('.flyfish-pptx-slide-slot').count() +assert.ok(slotCount > 1, `expected several slides, got ${slotCount}`) + +// Shortcuts are scoped to a viewer the user has explicitly activated. +await deep('.pptx-render-surface').click({ position: { x: 8, y: 8 } }) +await page.keyboard.press('F5') +await deep('.flyfish-pptx-presentation').waitFor({ state: 'visible', timeout: 10_000 }) +console.log('2) F5 opened the slideshow overlay') + +let state = await readState() +assert.equal(state.presenting, true, 'content should carry is-presenting') +assert.equal(state.visibleSlides, 1, `exactly one slide should be visible, got ${state.visibleSlides}`) +assert.equal(state.inOverlay, true, 'the scale box should have moved into the overlay') +assert.match(state.counter, /^1 \/ \d+$/, `unexpected counter ${state.counter}`) +assert.match(state.transform, /scale\(/, 'the active slide should be scaled to fit') +console.log(`3) one slide shown, counter "${state.counter}", scaled: ${state.transform}`) + +await page.keyboard.press('ArrowRight') +await page.waitForTimeout(250) +state = await readState() +assert.equal(state.activeNumber, '2', `ArrowRight should advance, active=${state.activeNumber}`) +assert.equal(state.visibleSlides, 1, 'still exactly one slide after advancing') +console.log(`4) ArrowRight advanced to slide ${state.activeNumber} (${state.counter})`) + +await page.keyboard.press('ArrowLeft') +await page.waitForTimeout(250) +state = await readState() +assert.equal(state.activeNumber, '1', `ArrowLeft should go back, active=${state.activeNumber}`) +console.log('5) ArrowLeft went back to slide 1') + +await page.mouse.click(1000, 400) +await page.waitForTimeout(250) +state = await readState() +assert.equal(state.activeNumber, '2', `right-side click should advance, active=${state.activeNumber}`) +await page.mouse.click(60, 400) +await page.waitForTimeout(250) +state = await readState() +assert.equal(state.activeNumber, '1', `left-edge click should go back, active=${state.activeNumber}`) +console.log('6) click navigation works in both directions') + +await page.keyboard.press('End') +await page.waitForTimeout(300) +state = await readState() +const total = Number(state.counter.split('/')[1].trim()) +assert.equal(Number(state.activeNumber), total, `End should jump to the last slide (${state.counter})`) +console.log(`7) End jumped to the last slide (${state.counter})`) + +await page.keyboard.press('Escape') +await page.waitForTimeout(400) +state = await readState() +assert.equal(state.overlay, false, 'Escape should remove the overlay') +assert.equal(state.presenting, false, 'is-presenting should be cleared') +const restored = await page.evaluate(`(() => { + ${DEEP} + const surface = deepOne('.pptx-render-surface') + const box = deepOne('.flyfish-pptx-scale-box') + const slots = deepAll('.flyfish-pptx-slide-slot') + return { + backInSurface: Boolean(surface && box && surface.contains(box)), + visible: slots.filter(s => getComputedStyle(s).display !== 'none').length, + } +})()`) +assert.equal(restored.backInSurface, true, 'the scale box should return to the render surface') +assert.ok(restored.visible > 1, `all slides should be visible again, got ${restored.visible}`) +console.log(`8) Escape restored the scroll view (${restored.visible} slides visible again)`) + +await page.keyboard.press('KeyP') +await deep('.flyfish-pptx-presentation').waitFor({ state: 'visible', timeout: 10_000 }) +state = await readState() +assert.equal(state.visibleSlides, 1, 'second entry should show one slide') +console.log(`9) P re-entered the slideshow (${state.counter})`) +await page.keyboard.press('Escape') +await page.waitForTimeout(300) + +// --------------------------------------------------------------------------- +// Part 2: API-level harness (default/non-windowed, resize, two instances, +// shadow fullscreen, focusable controls, unmount cleanup, scroll/transform) +// --------------------------------------------------------------------------- +await page.goto(`${BASE}/slideshow-test.html`, { waitUntil: 'load' }) +await page.waitForFunction( + () => document.documentElement.dataset.slideshowTestReady === 'true', + null, + { timeout: 60_000 } +) + +const call = (method, ...args) => page.evaluate( + ({ method, args }) => window.__slideshowTest[method](...args), + { method, args } +) +const waitOverlay = (count, timeout = 10_000) => page.waitForFunction( + expected => window.__slideshowTest.overlayCount() === expected, + count, + { timeout } +) + +// 10) default/non-windowed API: slideCount counts the deck, enter works, and +// the active slide is a real rendered slide, not an empty slot marker. +const defaultCount = await call('slideCount', 'default') +assert.equal(defaultCount, 20, `default viewer should count 20 slides, got ${defaultCount}`) +await call('enter', 'default') +await waitOverlay(1) +assert.equal(await call('activeNumber', 'default'), 1, 'default viewer should start on slide 1') +assert.equal(await call('activeSlideHasContent', 'default'), true, 'active slide should have content') +assert.match(await call('counter', 'default'), /^1 \/ 20$/, 'counter should read 1 / 20') +assert.match(await call('transform', 'default'), /scale\(/, 'non-windowed slide should be scaled to fit') +await page.keyboard.press('ArrowRight') +await page.waitForTimeout(250) +assert.equal(await call('activeNumber', 'default'), 2, 'non-windowed deck should navigate') +assert.equal(await call('activeSlideHasContent', 'default'), true, 'slide 2 should have content') +await call('exit', 'default') +await waitOverlay(0, 5_000) +console.log('10) default/non-windowed API: slideCount, enter, navigate, content, exit') + +// 11) virtualization must not unmount the active slide during a presentation. +await call('enter', 'windowed', 20) +await waitOverlay(1) +assert.equal(await call('activeNumber', 'windowed'), 20, 'windowed viewer should jump to slide 20') +assert.equal(await call('activeSlideHasContent', 'windowed'), true, 'slide 20 should be rendered') +await page.setViewportSize({ width: 1000, height: 700 }) +await page.waitForTimeout(400) +assert.equal(await call('activeNumber', 'windowed'), 20, 'active slide should survive a resize') +assert.equal(await call('activeSlideHasContent', 'windowed'), true, 'active slide should keep content after a resize') +await call('exit', 'windowed') +await waitOverlay(0, 5_000) +await page.setViewportSize({ width: 1280, height: 800 }) +console.log('11) active slide keeps its content after a resize') + +// 12) Host controls never activate a viewer shortcut. Once a viewer is +// explicitly activated, one P opens exactly one overlay; with two overlays +// open only the focused one advances. +await page.click('#unrelated-host-action') +await page.evaluate(() => { + window.__hostShortcutPrevented = null + document.addEventListener('keydown', event => { + if (event.key === 'p' || event.key === 'P') { + window.__hostShortcutPrevented = event.defaultPrevented + } + }, { once: true }) +}) +await page.keyboard.press('KeyP') +await page.waitForTimeout(250) +assert.equal(await call('overlayCount'), 0, 'P on an unrelated host control must not open a slideshow') +assert.equal(await page.evaluate(() => window.__hostShortcutPrevented), false, 'host P key must not be prevented') +await page.click('#renderer-b') +await page.keyboard.press('KeyP') +await waitOverlay(1) +console.log('12) host shortcut isolation and two-instance arbitration work') +await page.keyboard.press('KeyP') +await waitOverlay(0, 5_000) +await page.click('#renderer-a') +await page.keyboard.press('KeyP') +await waitOverlay(1) +await page.keyboard.press('KeyP') +await waitOverlay(0, 5_000) +await call('enter', 'default') +await call('enter', 'windowed', 1) +await waitOverlay(2) +const defaultBefore = await call('activeNumber', 'default') +const focusedBefore = await call('activeNumber', 'windowed') +await page.keyboard.press('ArrowRight') +await page.waitForTimeout(250) +assert.equal(await call('activeNumber', 'windowed'), focusedBefore + 1, 'focused overlay should advance') +assert.equal(await call('activeNumber', 'default'), defaultBefore, 'unfocused overlay should stay put') +await call('exit', 'default') +await call('exit', 'windowed') +await waitOverlay(0, 5_000) + +// 13) native fullscreen inside a shadow root: the browser's own exit closes the +// overlay instead of leaving a fixed layer behind. +await page.click('#enter-shadow') +await page.waitForFunction( + () => Boolean(window.__slideshowTest.fullscreenElementClass()), + null, + { timeout: 5_000 } +) +assert.equal(await call('overlayCount'), 1, 'shadow viewer should be presenting') +await call('exitFullscreen') +await waitOverlay(0, 5_000) +console.log('13) shadow-root native fullscreen exit closes the overlay') + +// 14) Enter/Space on the focused exit button activates it, not the slide. +await call('enter', 'default') +await waitOverlay(1) +await call('focusExitButton', 'default') +await page.keyboard.press('Enter') +await waitOverlay(0, 5_000) +await call('enter', 'default') +await waitOverlay(1) +await call('focusExitButton', 'default') +await page.keyboard.press('Space') +await waitOverlay(0, 5_000) +console.log('14) Enter/Space on the exit button exits instead of advancing') + +// 15) unmount/destroy while presenting cleans the overlay up. +await call('enter', 'windowed') +await waitOverlay(1) +await call('destroy', 'windowed') +await waitOverlay(0, 5_000) +await page.click('#renderer-a') +await page.keyboard.press('KeyP') +await waitOverlay(1) +await call('unmountRenderer', 'rendererA') +await waitOverlay(0, 5_000) +console.log('15) destroy/unmount while presenting removes the overlay') + +// 16) exiting from a scrolled deck restores the exact scroll position and the +// original scale transform. +await call('scrollTo', 600) +await page.waitForTimeout(100) +const beforeScroll = await call('scrollTop') +const beforeTransform = await call('transform', 'default') +assert.ok(beforeScroll >= 600, `expected the page to scroll to ~600, got ${beforeScroll}`) +await call('enter', 'default') +await waitOverlay(1) +await call('exit', 'default') +await waitOverlay(0, 5_000) +await page.waitForTimeout(300) +const afterScroll = await call('scrollTop') +const afterTransform = await call('transform', 'default') +assert.equal(afterScroll, beforeScroll, `scroll position should be restored, got ${afterScroll} expected ${beforeScroll}`) +assert.equal(afterTransform, beforeTransform, `transform should be restored, got "${afterTransform}" expected "${beforeTransform}"`) +console.log('16) scroll position and transform restored exactly') + +// 17) A renderer that rejects during worker startup returns no instance, so it +// must remove its document listener and shell before propagating the error. +await page.evaluate(() => { + const added = [] + const removed = [] + const originalAdd = document.addEventListener.bind(document) + const originalRemove = document.removeEventListener.bind(document) + window.__failedRendererListenerProbe = { added, removed, originalAdd, originalRemove } + document.addEventListener = (type, listener, options) => { + if (type === 'keydown') added.push(listener) + return originalAdd(type, listener, options) + } + document.removeEventListener = (type, listener, options) => { + if (type === 'keydown') removed.push(listener) + return originalRemove(type, listener, options) + } +}) +const failure = await call('renderFailure') +const failureListeners = await page.evaluate(() => { + const probe = window.__failedRendererListenerProbe + document.addEventListener = probe.originalAdd + document.removeEventListener = probe.originalRemove + return { + added: probe.added.length, + removed: probe.removed.length, + leaked: probe.added.filter(listener => !probe.removed.includes(listener)).length, + } +}) +assert.equal(failure.rejected, true, 'invalid worker startup should reject') +assert.equal(failure.childCount, 0, 'failed renderer shell should be removed') +assert.equal(failureListeners.added, 1, 'failed renderer should install one keydown listener') +assert.equal(failureListeners.removed, 1, 'failed renderer should remove its keydown listener') +assert.equal(failureListeners.leaked, 0, 'failed renderer must not leak a keydown listener') +console.log('17) failed renderer startup cleans its shell and document listener') + +await call('unmountRenderer', 'rendererB') + +assert.deepEqual(errors, [], `page errors: ${errors.join(' | ')}`) +console.log('\nall slideshow checks passed') +await browser.close() +server.close() diff --git a/apps/viewer-demo/slideshow-test.html b/apps/viewer-demo/slideshow-test.html new file mode 100644 index 00000000..5fc37cc2 --- /dev/null +++ b/apps/viewer-demo/slideshow-test.html @@ -0,0 +1,83 @@ + + + + + + PPTX slideshow test harness + + + +

PPTX slideshow test harness

+

Driven by verify-pptx-slideshow.mjs. Exposes window.__slideshowTest.

+ +
+ + + + +
+ +
+

default (non-windowed API)

+
+
+ +
+

windowed (lazySlides)

+
+
+ +
+

shadow root (non-windowed)

+
+
+ +
+

renderer A (renderPptx)

+
+
+ +
+

renderer B (renderPptx)

+
+
+ +
+ + + + diff --git a/apps/viewer-demo/src/slideshow-test.ts b/apps/viewer-demo/src/slideshow-test.ts new file mode 100644 index 00000000..0f58cab3 --- /dev/null +++ b/apps/viewer-demo/src/slideshow-test.ts @@ -0,0 +1,225 @@ +import { PptxViewer } from '@file-viewer/pptx' +import { renderFileViewerPresentation } from '@file-viewer/renderer-presentation' +import type { FileViewerRenderedInstance } from '@file-viewer/core' + +type ViewerName = 'default' | 'windowed' | 'shadow' +type RendererName = 'rendererA' | 'rendererB' + +interface SlideshowTestApi { + slideCount: (name: ViewerName) => number + enter: (name: ViewerName, slide?: number) => Promise + exit: (name: ViewerName) => void + presenting: (name: ViewerName) => boolean + activeNumber: (name: ViewerName) => number + activeSlideHasContent: (name: ViewerName) => boolean + counter: (name: ViewerName) => string + transform: (name: ViewerName) => string + overlayCount: () => number + scrollTop: () => number + scrollTo: (y: number) => void + focusExitButton: (name: ViewerName) => void + destroy: (name: ViewerName) => void + unmountRenderer: (name: RendererName) => void + renderFailure: () => Promise<{ rejected: boolean; childCount: number }> + exitFullscreen: () => Promise + fullscreenElementClass: () => string +} + +declare global { + interface Window { + __slideshowTest?: SlideshowTestApi + } +} + +const viewers = new Map() +const renderers = new Map() + +const destroyRenderedInstance = (instance: FileViewerRenderedInstance) => { + if ('unmount' in instance) { + void instance.unmount() + } else if ('$destroy' in instance) { + void instance.$destroy() + } else { + void instance.destroy() + } +} + +const deepAll = (selector: string, root: ParentNode = document): Element[] => { + const out: Element[] = [] + const walk = (node: ParentNode) => { + out.push(...Array.from(node.querySelectorAll(selector))) + for (const el of Array.from(node.querySelectorAll('*'))) { + if (el.shadowRoot) { + walk(el.shadowRoot) + } + } + } + walk(root) + return out +} + +const fetchSample = async () => { + const response = await fetch('/example/ppt.pptx') + if (!response.ok) { + throw new Error(`sample fetch failed: ${response.status}`) + } + return response.arrayBuffer() +} + +// The raw PptxViewer resolves its worker with `new URL('./worker/pptx.worker.js', +// import.meta.url)`, which the demo build does not rewrite for this entry chunk. +// Point it at the vendored worker copy the demo already ships instead. +const PPTX_WORKER_URL = '/vendor/pptx/pptx.worker.js' + +const mountShadowTarget = () => { + const host = document.getElementById('viewer-shadow-host')! + const shadow = host.attachShadow({ mode: 'open' }) + const target = document.createElement('div') + shadow.appendChild(target) + return target +} + +const overlayFor = (name: ViewerName) => { + const viewer = viewers.get(name) + if (!viewer) { + return null + } + return deepAll('.flyfish-pptx-presentation').find(overlay => overlay.contains(viewer.content)) || null +} + +const waitForReady = async () => { + const deadline = Date.now() + 60_000 + const allReady = () => { + const rawReady = (['default', 'windowed', 'shadow'] as ViewerName[]).every( + name => (viewers.get(name)?.slideCount ?? 0) === 20 + ) + const rendererReady = (['renderer-a', 'renderer-b'] as const).every(id => { + const element = document.getElementById(id) + return Boolean(element?.querySelector('.pptx-slideshow-button:not([hidden])')) + }) + return rawReady && rendererReady + } + while (!allReady()) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for the harness viewers to render') + } + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +const init = async () => { + const buffer = await fetchSample() + + // The default and windowed viewers are driven through evaluate, not a user + // gesture, so native fullscreen is disabled and the overlay stays fixed. + viewers.set('default', await PptxViewer.open(buffer, document.getElementById('viewer-default')!, { + workerUrl: PPTX_WORKER_URL, + presentationFullscreen: false, + })) + viewers.set('windowed', await PptxViewer.open(buffer, document.getElementById('viewer-windowed')!, { + workerUrl: PPTX_WORKER_URL, + presentationFullscreen: false, + lazySlides: true, + listOptions: { windowed: true, initialSlides: 3, batchSize: 4, overscanViewport: 1.5 }, + })) + viewers.set('shadow', await PptxViewer.open(buffer, mountShadowTarget(), { + workerUrl: PPTX_WORKER_URL, + })) + + renderers.set('rendererA', await renderFileViewerPresentation( + buffer, + document.getElementById('renderer-a') as HTMLDivElement, + 'pptx', + {} + )) + renderers.set('rendererB', await renderFileViewerPresentation( + buffer, + document.getElementById('renderer-b') as HTMLDivElement, + 'pptx', + {} + )) + + await waitForReady() + + // Real user gestures so requestFullscreen is allowed in the browser. + document.getElementById('enter-shadow')!.addEventListener('click', () => { + void viewers.get('shadow')?.enterPresentation() + }) + document.getElementById('enter-default')!.addEventListener('click', () => { + void viewers.get('default')?.enterPresentation() + }) + document.getElementById('enter-windowed')!.addEventListener('click', () => { + void viewers.get('windowed')?.enterPresentation() + }) + + window.__slideshowTest = { + slideCount: name => viewers.get(name)?.slideCount ?? 0, + enter: async (name, slide) => { + await viewers.get(name)?.enterPresentation(slide) + }, + exit: name => viewers.get(name)?.exitPresentation(), + presenting: name => Boolean(viewers.get(name)?.presenting), + activeNumber: name => viewers.get(name)?.presentationSlideNumber ?? 0, + activeSlideHasContent: name => { + const viewer = viewers.get(name) + if (!viewer) { + return false + } + const containers = Array.from( + viewer.content.querySelectorAll(':scope > .flyfish-pptx-slide-slot, :scope > .slide') + ) + const active = containers.find(container => container.classList.contains('is-active-slide')) + if (!active) { + return false + } + const slide = active.classList.contains('flyfish-pptx-slide-slot') + ? active.firstElementChild + : active + return Boolean(slide && slide.classList.contains('slide') && slide.textContent?.trim()) + }, + counter: name => overlayFor(name)?.querySelector('.flyfish-pptx-presentation-counter')?.textContent ?? '', + transform: name => viewers.get(name)?.content.style.transform ?? '', + overlayCount: () => deepAll('.flyfish-pptx-presentation').length, + scrollTop: () => document.scrollingElement?.scrollTop ?? 0, + scrollTo: y => window.scrollTo(0, y), + focusExitButton: name => { + const button = overlayFor(name)?.querySelector('.flyfish-pptx-presentation-exit') + button?.focus() + }, + destroy: name => { + viewers.get(name)?.destroy() + viewers.delete(name) + }, + unmountRenderer: name => { + const instance = renderers.get(name) + if (instance) { + destroyRenderedInstance(instance) + } + renderers.delete(name) + }, + renderFailure: async () => { + const target = document.getElementById('renderer-failure') as HTMLDivElement + let rejected = false + try { + await renderFileViewerPresentation(buffer, target, 'pptx', { + options: { presentation: { workerUrl: '/missing-pptx-worker.js' } }, + }) + } catch { + rejected = true + } + return { rejected, childCount: target.childElementCount } + }, + exitFullscreen: () => document.exitFullscreen(), + fullscreenElementClass: () => { + const element = document.fullscreenElement + return element ? element.className || element.tagName : '' + }, + } + + document.documentElement.dataset.slideshowTestReady = 'true' +} + +init().catch(error => { + console.error('slideshow test harness failed to init:', error) + document.documentElement.dataset.slideshowTestReady = 'error' +}) diff --git a/apps/viewer-demo/tsconfig.json b/apps/viewer-demo/tsconfig.json index 5a1fe838..6717df37 100644 --- a/apps/viewer-demo/tsconfig.json +++ b/apps/viewer-demo/tsconfig.json @@ -19,6 +19,8 @@ "@/*": ["./src/*"], "@file-viewer/core": ["../../packages/core/src/index.ts"], "@file-viewer/core/browser": ["../../packages/core/src/browser.ts"], + "@file-viewer/pptx": ["../../packages/renderers/pptx/src/index.ts"], + "@file-viewer/renderer-presentation": ["../../packages/renderers/presentation/src/index.ts"], "@file-viewer/vue3": ["../../packages/components/vue3/src/package/index.ts"], "@file-viewer/web": ["../../packages/components/web/src/index.ts"], "@flyfish-group/file-viewer3": ["../../packages/components/vue3/src/package/index.ts"] diff --git a/apps/viewer-demo/vite.config.ts b/apps/viewer-demo/vite.config.ts index a92bbb99..0aea78b9 100644 --- a/apps/viewer-demo/vite.config.ts +++ b/apps/viewer-demo/vite.config.ts @@ -108,7 +108,8 @@ export default defineConfig(ctx => { input: { main: fileURLToPath(new URL('index.html', import.meta.url)), compare: fileURLToPath(new URL('compare.html', import.meta.url)), - iframe: fileURLToPath(new URL('iframe.html', import.meta.url)) + iframe: fileURLToPath(new URL('iframe.html', import.meta.url)), + 'slideshow-test': fileURLToPath(new URL('slideshow-test.html', import.meta.url)) } }, outDir: 'dist' diff --git a/package.json b/package.json index deb25388..715f7183 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "docs:preview": "vitepress preview docs", "verify:vue3-toolbar-browser": "node apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs", "verify:renderer-sanitization": "pnpm --filter @file-viewer/pptx build && node apps/viewer-demo/scripts/verify-renderer-sanitization.mjs", - "verify:browser-smoke": "node apps/viewer-demo/scripts/public-smoke.mjs" + "verify:browser-smoke": "node apps/viewer-demo/scripts/public-smoke.mjs", + "verify:pptx-slideshow": "node apps/viewer-demo/scripts/verify-pptx-slideshow.mjs" }, "devDependencies": { "@types/node": "^25.9.2", diff --git a/packages/core/src/contracts/types.ts b/packages/core/src/contracts/types.ts index aec7666b..60159448 100644 --- a/packages/core/src/contracts/types.ts +++ b/packages/core/src/contracts/types.ts @@ -148,6 +148,9 @@ export type FileViewerMessageKey = | 'presentation.ppt.error.title' | 'presentation.ppt.error.parseFailed' | 'presentation.ppt.error.assetHint' + | 'presentation.slideshow.start' + | 'presentation.slideshow.exit' + | 'presentation.slideshow.hint' | 'archive.error.nestedUnsupported' | 'archive.loading.readingDirectory' | 'archive.loading.readingDirectoryHint' diff --git a/packages/core/src/i18n/messages.ja.ts b/packages/core/src/i18n/messages.ja.ts index c2f0ba63..d4f3a4ac 100644 --- a/packages/core/src/i18n/messages.ja.ts +++ b/packages/core/src/i18n/messages.ja.ts @@ -140,6 +140,9 @@ export const JA_JP_MESSAGES: Record = { 'presentation.ppt.error.title': 'PPT プレビューに失敗しました', 'presentation.ppt.error.parseFailed': 'この PPT ファイルを解析できませんでした。', 'presentation.ppt.error.assetHint': '@file-viewer/ppt モジュール、pptWasmUrl、application/wasm MIME タイプ、Web Crypto、CSP 設定を確認してください。', + 'presentation.slideshow.start': 'スライドショーを開始', + 'presentation.slideshow.exit': 'スライドショーを終了', + 'presentation.slideshow.hint': '← → 移動 · Esc 終了', 'archive.error.nestedUnsupported': '.{type} 形式はオンラインでプレビューできません。ダウンロードするか、対応形式へ変換してください。', 'archive.loading.readingDirectory': '圧縮ファイルのディレクトリを読み込んでいます...', 'archive.loading.readingDirectoryHint': '大きなファイルは Worker で解析し、メインスレッドのブロックを防ぎます。', diff --git a/packages/core/src/i18n/messages.ts b/packages/core/src/i18n/messages.ts index c3f562fb..90bd0a80 100644 --- a/packages/core/src/i18n/messages.ts +++ b/packages/core/src/i18n/messages.ts @@ -157,6 +157,9 @@ const ZH_CN_MESSAGES: Record = { 'presentation.ppt.error.title': 'PPT 预览失败', 'presentation.ppt.error.parseFailed': '无法解析这个 PPT 文件。', 'presentation.ppt.error.assetHint': '请检查 @file-viewer/ppt 模块、pptWasmUrl、application/wasm MIME 类型、Web Crypto 和 CSP 配置。', + 'presentation.slideshow.start': '开始放映', + 'presentation.slideshow.exit': '退出放映', + 'presentation.slideshow.hint': '← → 翻页 · Esc 退出', 'archive.error.nestedUnsupported': '不支持 .{type} 格式的在线预览,请下载后预览或转换为支持的格式', 'archive.loading.readingDirectory': '正在读取压缩包目录...', 'archive.loading.readingDirectoryHint': '大文件会在 Worker 中解析,避免阻塞主线程。', @@ -594,6 +597,9 @@ const EN_US_MESSAGES: Record = { 'presentation.ppt.error.title': 'PPT preview failed', 'presentation.ppt.error.parseFailed': 'This PPT file could not be parsed.', 'presentation.ppt.error.assetHint': 'Check the @file-viewer/ppt module, pptWasmUrl, application/wasm MIME type, Web Crypto, and CSP settings.', + 'presentation.slideshow.start': 'Start slideshow', + 'presentation.slideshow.exit': 'Exit slideshow', + 'presentation.slideshow.hint': '← → navigate · Esc exit', 'archive.error.nestedUnsupported': '.{type} cannot be previewed online. Download it or convert it to a supported format.', 'archive.loading.readingDirectory': 'Reading archive directory...', 'archive.loading.readingDirectoryHint': 'Large files are parsed in a Worker to keep the main thread responsive.', diff --git a/packages/renderers/pptx/src/index.ts b/packages/renderers/pptx/src/index.ts index 948170dd..710164f5 100644 --- a/packages/renderers/pptx/src/index.ts +++ b/packages/renderers/pptx/src/index.ts @@ -1,4 +1,6 @@ export { PptxViewer } from './viewer'; +export { PptxPresentation } from './presentation'; +export type { PptxPresentationLabels, PptxPresentationState } from './presentation'; export { createPptxWorker } from './worker'; export { RECOMMENDED_ZIP_LIMITS, diff --git a/packages/renderers/pptx/src/presentation.ts b/packages/renderers/pptx/src/presentation.ts new file mode 100644 index 00000000..1e7f6dec --- /dev/null +++ b/packages/renderers/pptx/src/presentation.ts @@ -0,0 +1,432 @@ +import type { PptxViewer } from './viewer'; + +export interface PptxPresentationState { + active: boolean; + slideNumber: number; + total: number; +} + +export interface PptxPresentationLabels { + exit?: string; + hint?: string; + next?: string; + previous?: string; +} + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +/** + * Full-screen slideshow for a rendered PPTX deck. + * + * The slides stay where the viewer put them: the whole scale box is moved into an overlay and the + * inactive slots are hidden with CSS, so the engine's scoped `.flyfish-pptx-content .slide` rules + * keep applying and no node is cloned. A placeholder marks the original position so exiting puts + * everything back exactly where it was. + */ +export class PptxPresentation { + private readonly viewer: PptxViewer; + private readonly labels: PptxPresentationLabels; + private readonly fullscreen: boolean; + private overlay: HTMLDivElement | null = null; + private stage: HTMLDivElement | null = null; + private counter: HTMLDivElement | null = null; + private placeholder: Comment | null = null; + private listeners: Array<{ target: EventTarget; type: string; listener: EventListener }> = []; + private layoutFrame = 0; + private ownsFullscreen = false; + private current = 1; + + constructor(viewer: PptxViewer, labels: PptxPresentationLabels = {}, fullscreen = true) { + this.viewer = viewer; + this.labels = labels; + this.fullscreen = fullscreen; + } + + get active() { + return Boolean(this.overlay); + } + + get slideNumber() { + return this.current; + } + + get state(): PptxPresentationState { + return { active: this.active, slideNumber: this.current, total: this.viewer.slideCount }; + } + + async enter(slideNumber = this.current) { + if (this.active || this.viewer.slideCount === 0) { + return; + } + + const documentRef = this.viewer.target.ownerDocument || document; + const overlay = documentRef.createElement('div'); + overlay.className = 'flyfish-pptx-presentation'; + overlay.tabIndex = -1; + overlay.setAttribute('role', 'region'); + overlay.setAttribute('aria-label', this.labels.hint || 'Slideshow'); + + const stage = documentRef.createElement('div'); + stage.className = 'flyfish-pptx-presentation-stage'; + + const counter = documentRef.createElement('div'); + counter.className = 'flyfish-pptx-presentation-counter'; + + const hint = documentRef.createElement('div'); + hint.className = 'flyfish-pptx-presentation-hint'; + hint.textContent = this.labels.hint || ''; + hint.hidden = !this.labels.hint; + + const exit = documentRef.createElement('button'); + exit.type = 'button'; + exit.className = 'flyfish-pptx-presentation-exit'; + exit.textContent = '✕'; + exit.setAttribute('aria-label', this.labels.exit || 'Exit slideshow'); + exit.addEventListener('click', event => { + event.stopPropagation(); + this.exit(); + }); + + overlay.append(stage, counter, hint, exit); + this.overlay = overlay; + this.stage = stage; + this.counter = counter; + + // Keep the overlay inside whichever root holds the slide styles, or the engine's scoped CSS + // would not reach the slides once they move. + const styleRoot = this.viewer.presentationRoot; + styleRoot.appendChild(overlay); + + // Park a comment where the scale box lived so exit() can restore the exact position. + this.placeholder = documentRef.createComment('flyfish-pptx-presentation'); + this.viewer.saveScrollPosition(); + this.viewer.scaleBox.replaceWith(this.placeholder); + stage.appendChild(this.viewer.scaleBox); + this.viewer.content.classList.add('is-presenting'); + + this.attach(documentRef, overlay); + this.goTo(slideNumber); + + if (this.fullscreen) { + try { + await overlay.requestFullscreen?.(); + this.ownsFullscreen = this.isFullscreenElement(overlay); + } catch { + // Fullscreen needs a user gesture and can be blocked by permissions policy. The overlay is + // position:fixed, so the slideshow still fills the viewport without it. + this.ownsFullscreen = false; + } + } + + // exit() may have run while fullscreen was being requested. Leave the + // browser out of fullscreen rather than stranded with no overlay. + if (this.overlay !== overlay) { + if (this.ownsFullscreen) { + void documentRef.exitFullscreen?.().catch(() => undefined); + } + this.ownsFullscreen = false; + return; + } + + overlay.focus({ preventScroll: true }); + this.scheduleLayout(); + this.notify(); + } + + exit() { + const overlay = this.overlay; + if (!overlay) { + return; + } + + const documentRef = this.viewer.target.ownerDocument || document; + // Capture before the overlay leaves the DOM: a detached node's getRootNode() + // is the document, so the fullscreen check would fail after removal. + const shouldExitFullscreen = this.ownsFullscreen && this.isFullscreenElement(overlay); + this.detach(); + + if (this.layoutFrame) { + (documentRef.defaultView || window).cancelAnimationFrame(this.layoutFrame); + this.layoutFrame = 0; + } + + this.viewer.content.classList.remove('is-presenting'); + for (const container of this.slideContainers()) { + container.classList.remove('is-active-slide'); + } + + if (this.placeholder?.parentNode) { + this.placeholder.replaceWith(this.viewer.scaleBox); + } else { + this.viewer.target.appendChild(this.viewer.scaleBox); + } + this.placeholder = null; + + overlay.remove(); + this.overlay = null; + this.stage = null; + this.counter = null; + + if (shouldExitFullscreen) { + void documentRef.exitFullscreen?.().catch(() => undefined); + } + this.ownsFullscreen = false; + + this.viewer.restoreScrollPosition(); + this.notify(); + } + + async toggle(slideNumber?: number) { + if (this.active) { + this.exit(); + return; + } + await this.enter(slideNumber); + } + + goTo(slideNumber: number) { + const total = this.viewer.slideCount; + if (total === 0) { + return; + } + + this.current = clamp(Math.round(slideNumber) || 1, 1, total); + this.viewer.ensureSlideRendered(this.current); + // Rendering the neighbour keeps the next step instant without defeating virtualization. + this.viewer.ensureSlideRendered(this.current + 1); + + const containers = this.slideContainers(); + // Windowed decks tag each slot with its slide number; non-windowed decks + // append slides in order, so the active one is found by index. + const byNumber = containers.some(container => Boolean(container.dataset.slideNumber)); + containers.forEach((container, index) => { + const isActive = byNumber + ? Number(container.dataset.slideNumber) === this.current + : index === this.current - 1; + container.classList.toggle('is-active-slide', isActive); + }); + + if (this.counter) { + this.counter.textContent = `${this.current} / ${total}`; + } + + if (this.active) { + this.scheduleLayout(); + this.notify(); + } + } + + next() { + if (this.current < this.viewer.slideCount) { + this.goTo(this.current + 1); + } + } + + previous() { + if (this.current > 1) { + this.goTo(this.current - 1); + } + } + + /** Scale the active slide to fit the overlay, letterboxing whichever axis has slack. */ + layout() { + const overlay = this.overlay; + if (!overlay) { + return; + } + + const container = this.slideContainers().find((item, index) => { + if (item.dataset.slideNumber) { + return Number(item.dataset.slideNumber) === this.current; + } + return index === this.current - 1; + }); + // Windowed decks wrap the slide in a slot; non-windowed decks are the slide. + const slide = container && container.classList.contains('flyfish-pptx-slide-slot') + ? container.firstElementChild as HTMLElement | null + : container as HTMLElement | null; + const size = this.viewer.slideDimensions; + const slideWidth = slide?.offsetWidth || size?.width || 0; + const slideHeight = slide?.offsetHeight || size?.height || 0; + if (!slideWidth || !slideHeight) { + return; + } + + const viewWidth = overlay.clientWidth; + const viewHeight = overlay.clientHeight; + const scale = Math.min(viewWidth / slideWidth, viewHeight / slideHeight); + const offsetX = Math.max(0, (viewWidth - slideWidth * scale) / 2); + const offsetY = Math.max(0, (viewHeight - slideHeight * scale) / 2); + + const content = this.viewer.content; + content.style.width = `${slideWidth}px`; + content.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + + const scaleBox = this.viewer.scaleBox; + scaleBox.style.width = '100%'; + scaleBox.style.height = '100%'; + scaleBox.style.minHeight = ''; + } + + destroy() { + this.exit(); + } + + private slots() { + return Array.from( + this.viewer.content.querySelectorAll(':scope > .flyfish-pptx-slide-slot') + ); + } + + /** + * The nodes the slideshow toggles between. Windowed decks wrap each slide in a + * slot; the default (non-windowed) deck appends slides directly to the content + * node, so those are the containers there. + */ + private slideContainers() { + const slots = this.slots(); + if (slots.length > 0) { + return slots; + } + return Array.from( + this.viewer.content.querySelectorAll(':scope > .slide') + ); + } + + /** + * Chromium reports the fullscreen element of a shadow root on the shadow root + * itself while document.fullscreenElement stays on the host, so both have to + * be checked before deciding who owns fullscreen. + */ + private isFullscreenElement(overlay: HTMLElement) { + const documentRef = this.viewer.target.ownerDocument || document; + if (documentRef.fullscreenElement === overlay) { + return true; + } + const root = overlay.getRootNode(); + if (root !== documentRef && 'fullscreenElement' in root) { + return (root as ShadowRoot).fullscreenElement === overlay; + } + return false; + } + + private scheduleLayout() { + const view = this.viewer.target.ownerDocument.defaultView || window; + if (this.layoutFrame) { + view.cancelAnimationFrame(this.layoutFrame); + } + this.layoutFrame = view.requestAnimationFrame(() => { + this.layoutFrame = 0; + this.layout(); + }); + } + + private notify() { + this.viewer.emitPresentationChange(this.state); + } + + private on(target: EventTarget, type: string, listener: EventListener) { + target.addEventListener(type, listener); + this.listeners.push({ target, type, listener }); + } + + private attach(documentRef: Document, overlay: HTMLElement) { + const view = documentRef.defaultView || window; + + this.on(documentRef, 'keydown', (event: Event) => this.handleKey(event as KeyboardEvent)); + this.on(overlay, 'click', (event: Event) => this.handleClick(event as MouseEvent)); + this.on(overlay, 'contextmenu', (event: Event) => { + event.preventDefault(); + this.previous(); + }); + this.on(view, 'resize', () => this.scheduleLayout()); + this.on(documentRef, 'fullscreenchange', () => { + // Leaving fullscreen with the browser's own Esc must close the slideshow too, otherwise the + // overlay would stay up as a plain fixed layer. + if (this.ownsFullscreen && !this.isFullscreenElement(overlay)) { + this.ownsFullscreen = false; + this.exit(); + } + }); + } + + private detach() { + for (const { target, type, listener } of this.listeners) { + target.removeEventListener(type, listener); + } + this.listeners = []; + } + + private handleClick(event: MouseEvent) { + const overlay = this.overlay; + if (!overlay || (event.target as HTMLElement | null)?.closest('.flyfish-pptx-presentation-exit')) { + return; + } + // PowerPoint advances on click; the left edge is the only place that goes back. + const bounds = overlay.getBoundingClientRect(); + const ratio = (event.clientX - bounds.left) / (bounds.width || 1); + if (ratio < 0.2) { + this.previous(); + } else { + this.next(); + } + } + + private handleKey(event: KeyboardEvent) { + if (!this.active || event.altKey || event.metaKey || event.ctrlKey) { + return; + } + + // Enter/Space on a focused control (the exit button) must activate that + // control, not advance the slide. Let the browser's default run instead. + const target = event.target as HTMLElement | null; + if ( + target && + typeof target.closest === 'function' && + target.closest('button, a, input, textarea, select, [contenteditable="true"], [contenteditable=""]') + ) { + return; + } + + // With two slideshows open, a key pressed inside the other overlay belongs + // to that overlay; only the focused one advances. + if (target && typeof target.closest === 'function') { + const otherOverlay = target.closest('.flyfish-pptx-presentation'); + if (otherOverlay && otherOverlay !== this.overlay) { + return; + } + } + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + case 'PageDown': + case ' ': + case 'Enter': + event.preventDefault(); + this.next(); + break; + case 'ArrowLeft': + case 'ArrowUp': + case 'PageUp': + case 'Backspace': + event.preventDefault(); + this.previous(); + break; + case 'Home': + event.preventDefault(); + this.goTo(1); + break; + case 'End': + event.preventDefault(); + this.goTo(this.viewer.slideCount); + break; + case 'Escape': + event.preventDefault(); + this.exit(); + break; + default: + break; + } + } +} diff --git a/packages/renderers/pptx/src/styles.ts b/packages/renderers/pptx/src/styles.ts index c0116c2a..e6484780 100644 --- a/packages/renderers/pptx/src/styles.ts +++ b/packages/renderers/pptx/src/styles.ts @@ -63,6 +63,24 @@ export const pptxViewerCss = ` ${scopePptxContentStyleText(legacyPptxCss)} .flyfish-pptx-content > .slide:last-of-type{margin-bottom:0} .flyfish-pptx-slide-slot:last-of-type > .slide{margin-bottom:0} +.flyfish-pptx-presentation{position:fixed;inset:0;z-index:2147483000;display:block;overflow:hidden;background:#000;cursor:default;user-select:none;-webkit-user-select:none} +.flyfish-pptx-presentation:focus{outline:none} +.flyfish-pptx-presentation-stage{position:absolute;inset:0;overflow:hidden} +.flyfish-pptx-presentation .flyfish-pptx-scale-box{position:absolute;inset:0;width:100%;height:100%;margin:0} +.flyfish-pptx-presentation .flyfish-pptx-thumbnail{display:none} +.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot{display:none;min-height:0} +.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot.is-active-slide{display:flow-root} +.flyfish-pptx-content.is-presenting > .slide{display:none} +.flyfish-pptx-content.is-presenting > .slide.is-active-slide{display:block} +.flyfish-pptx-content.is-presenting .slide{margin:0!important;box-shadow:none} +.flyfish-pptx-presentation-counter{position:absolute;right:18px;bottom:14px;padding:5px 12px;border-radius:999px;background:rgba(255,255,255,.14);color:#fff;font:13px/1.4 system-ui,sans-serif;letter-spacing:.02em;pointer-events:none} +.flyfish-pptx-presentation-hint{position:absolute;left:18px;bottom:14px;color:rgba(255,255,255,.55);font:12px/1.4 system-ui,sans-serif;pointer-events:none} +.flyfish-pptx-presentation-hint[hidden]{display:none} +.flyfish-pptx-presentation-exit{position:absolute;top:14px;right:14px;width:34px;height:34px;padding:0;border:0;border-radius:999px;background:rgba(255,255,255,.14);color:#fff;font-size:15px;line-height:1;cursor:pointer} +.flyfish-pptx-presentation-exit:hover{background:rgba(255,255,255,.26)} +.flyfish-pptx-presentation-exit:focus-visible{outline:2px solid #fff;outline-offset:2px} +@media (prefers-reduced-motion:no-preference){.flyfish-pptx-content.is-presenting > .flyfish-pptx-slide-slot.is-active-slide,.flyfish-pptx-content.is-presenting > .slide.is-active-slide{animation:flyfish-pptx-slide-in 140ms ease-out}} +@keyframes flyfish-pptx-slide-in{from{opacity:.35}to{opacity:1}} `; const PPTX_STYLE_ID = 'flyfish-pptx-native-style'; diff --git a/packages/renderers/pptx/src/types.ts b/packages/renderers/pptx/src/types.ts index 72d67f57..20ea25c0 100644 --- a/packages/renderers/pptx/src/types.ts +++ b/packages/renderers/pptx/src/types.ts @@ -1,3 +1,5 @@ +import type { PptxPresentationLabels, PptxPresentationState } from './presentation'; + export type PptxFitMode = 'contain' | 'none'; export interface PptxZipLimits { @@ -80,6 +82,9 @@ export interface PptxViewerOptions extends PptxWorkerFactoryOptions { onRenderComplete?: () => void; onWarning?: (warning: unknown) => void; onError?: (error: unknown) => void; + presentationLabels?: PptxPresentationLabels; + presentationFullscreen?: boolean; + onPresentationChange?: (state: PptxPresentationState) => void; } export interface PptxWorkerMessage { diff --git a/packages/renderers/pptx/src/viewer.ts b/packages/renderers/pptx/src/viewer.ts index ae9da13b..978e1c6e 100644 --- a/packages/renderers/pptx/src/viewer.ts +++ b/packages/renderers/pptx/src/viewer.ts @@ -1,6 +1,8 @@ import { renderPptxPostProcessing } from './chart'; import type { PptxPostProcessingHandle } from './chart'; import { resolvePptxEngineOptions, RECOMMENDED_ZIP_LIMITS } from './options'; +import { PptxPresentation } from './presentation'; +import type { PptxPresentationState } from './presentation'; import { sanitizePptxCss, sanitizePptxMarkup } from './sanitize'; import { ensurePptxViewerStyles } from './styles'; import type { PptxDiagnosticError, PptxSlideSize, PptxViewerOptions, PptxWorkerMessage } from './types'; @@ -174,6 +176,13 @@ export class PptxViewer { private previewThumbnailDataUrl: string | null = null; private slideSize: PptxSlideSize | null = null; private slideRecords: WindowedSlideRecord[] = []; + // Total slides received from the worker. The windowed path mirrors this in + // slideRecords, but the default (non-windowed) path appends slides straight + // into the content node without creating records, so the deck size has to be + // counted independently of the virtualization bookkeeping. + private totalSlides = 0; + private savedScroll: { top: number; left: number } | null = null; + private pendingScrollRestore: { top: number; left: number } | null = null; private slideWindowTarget: HTMLElement | Window | null = null; private slideWindowListeners: Array<{ target: EventTarget; type: string; listener: EventListener }> = []; private slideWindowFrame = 0; @@ -182,6 +191,7 @@ export class PptxViewer { private readonly mediaRecords = new Map(); private disposed = false; private completed = false; + private presentation: PptxPresentation | null = null; private readonly handleSlideWindowChange = () => this.scheduleSlideWindowUpdate(); private constructor(buffer: ArrayBuffer, target: HTMLElement, options: PptxViewerOptions) { @@ -208,6 +218,111 @@ export class PptxViewer { return this.previewThumbnailDataUrl; } + get slideCount() { + return this.totalSlides; + } + + get slideDimensions() { + const width = Number(this.slideSize?.width); + const height = Number(this.slideSize?.height); + return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0 + ? { width, height } + : null; + } + + get presenting() { + return Boolean(this.presentation?.active); + } + + get presentationSlideNumber() { + return this.presentation?.slideNumber ?? 1; + } + + /** + * Where the slideshow overlay is mounted. It has to share a root with the injected slide styles, + * or the engine's scoped CSS stops applying once the slides move into the overlay. + */ + get presentationRoot(): ShadowRoot | HTMLElement { + const documentRef = this.target.ownerDocument || document; + if (this.options.styleRoot) { + return this.options.styleRoot; + } + const root = this.target.getRootNode(); + const ShadowRootCtor = documentRef.defaultView?.ShadowRoot; + if (ShadowRootCtor && root instanceof ShadowRootCtor) { + return root as ShadowRoot; + } + return documentRef.body || documentRef.documentElement; + } + + /** Force a slide out of the virtualized window so the slideshow can show it immediately. */ + ensureSlideRendered(slideNumber: number) { + const record = this.slideRecords.find(item => item.slideNumber === slideNumber); + if (!record) { + return null; + } + if (!record.rendered) { + this.renderSlideRecord(record); + } + return record.element; + } + + refreshLayout() { + this.scheduleResize(); + } + + /** + * Remember where the deck was scrolled before the slideshow moves the scale + * box into the overlay. Moving it collapses the scroller and clamps scrollTop + * to zero, so the position has to be captured up front and restored on exit. + */ + saveScrollPosition() { + const view = this.target.ownerDocument.defaultView || window; + const HTMLElementCtor = view.HTMLElement; + const scroller = this.slideWindowTarget || this.findSlideWindowTarget(); + if (scroller instanceof HTMLElementCtor) { + this.savedScroll = { top: scroller.scrollTop, left: scroller.scrollLeft }; + } else { + this.savedScroll = { top: view.scrollY, left: view.scrollX }; + } + } + + restoreScrollPosition() { + if (this.savedScroll) { + this.pendingScrollRestore = this.savedScroll; + this.savedScroll = null; + } + this.scheduleResize(); + } + + emitPresentationChange(state: PptxPresentationState) { + this.options.onPresentationChange?.(state); + } + + async enterPresentation(slideNumber?: number) { + if (this.disposed || this.totalSlides === 0) { + return; + } + this.presentation ||= new PptxPresentation( + this, + this.options.presentationLabels, + this.options.presentationFullscreen + ); + await this.presentation.enter(slideNumber); + } + + exitPresentation() { + this.presentation?.exit(); + } + + async togglePresentation(slideNumber?: number) { + if (this.presenting) { + this.exitPresentation(); + return; + } + await this.enterPresentation(slideNumber); + } + async open() { ensureZipWithinLimits(this.buffer, this.options); ensurePptxViewerStyles(this.target.ownerDocument || document, this.options.styleRoot); @@ -225,6 +340,8 @@ export class PptxViewer { destroy() { this.disposed = true; + this.presentation?.destroy(); + this.presentation = null; this.previewThumbnailDataUrl = null; this.releaseCharts(); this.releaseMedia(); @@ -251,6 +368,7 @@ export class PptxViewer { this.previewThumbnailDataUrl = null; this.slideSize = null; this.slideRecords = []; + this.totalSlides = 0; this.content.replaceChildren(); this.content.dataset.renderState = 'loading'; try { @@ -298,6 +416,7 @@ export class PptxViewer { switch (message.type) { case 'slide': { this.clearThumbnail(); + this.totalSlides += 1; if (this.shouldWindowSlides()) { this.appendWindowedSlide(String(message.data || ''), Number(message.slide_num || 0)); } else { @@ -316,6 +435,7 @@ export class PptxViewer { const error = message.data; const html = buildSlideErrorHtml(slideNumber, error); this.clearThumbnail(); + this.totalSlides += 1; if (this.shouldWindowSlides()) { this.appendWindowedSlide(html, slideNumber); } else { @@ -558,6 +678,23 @@ export class PptxViewer { const windowOptions = this.getSlideWindowOptions(); const indexesToRender = this.getWindowedSlideIndexes(windowOptions); + + // While presenting, the active slide and its neighbour live inside the fixed + // overlay, outside the scroll-view geometry the windowing code measures. Keep + // them mounted so a resize cannot unmount the slide that is on screen. + const presentation = this.presentation; + if (presentation?.active) { + const activeIndex = this.slideRecords.findIndex( + record => record.slideNumber === presentation.slideNumber + ); + if (activeIndex >= 0) { + indexesToRender.add(activeIndex); + if (activeIndex + 1 < this.slideRecords.length) { + indexesToRender.add(activeIndex + 1); + } + } + } + let changed = false; this.slideRecords.forEach((record, index) => { @@ -959,6 +1096,12 @@ export class PptxViewer { } private resize() { + // While presenting, the slideshow owns the transform of the same nodes. + if (this.presentation?.active) { + this.presentation.layout(); + return; + } + const slides = this.getMountedSlideElements(); const sizeWidth = Number(this.slideSize?.width); @@ -980,6 +1123,22 @@ export class PptxViewer { this.scaleBox.style.width = `${Math.ceil(slideWidth * effectiveScale)}px`; this.scaleBox.style.height = `${Math.ceil(this.content.scrollHeight * effectiveScale)}px`; this.scaleBox.style.minHeight = ''; + + // The scale box height is recomputed above; only now can the saved scroll + // position be applied without the scroller clamping it back to zero. + if (this.pendingScrollRestore) { + const { top, left } = this.pendingScrollRestore; + this.pendingScrollRestore = null; + const view = this.target.ownerDocument.defaultView || window; + const HTMLElementCtor = view.HTMLElement; + const scroller = this.slideWindowTarget || this.findSlideWindowTarget(); + if (scroller instanceof HTMLElementCtor) { + scroller.scrollTop = top; + scroller.scrollLeft = left; + } else { + view.scrollTo(left, top); + } + } } private getMountedSlideElements() { diff --git a/packages/renderers/pptx/tsconfig.json b/packages/renderers/pptx/tsconfig.json index 1ceaf28b..855c4770 100644 --- a/packages/renderers/pptx/tsconfig.json +++ b/packages/renderers/pptx/tsconfig.json @@ -24,6 +24,7 @@ "src/styles.ts", "src/chart.ts", "src/worker.ts", - "src/viewer.ts" + "src/viewer.ts", + "src/presentation.ts" ] } diff --git a/packages/renderers/presentation/src/pptx.ts b/packages/renderers/presentation/src/pptx.ts index e037a338..07db64f9 100644 --- a/packages/renderers/presentation/src/pptx.ts +++ b/packages/renderers/presentation/src/pptx.ts @@ -29,6 +29,14 @@ const pptxStyle = ` .pptx-loading[hidden],.pptx-error[hidden]{display:none!important} .pptx-loading-dot{width:9px;height:9px;border-radius:999px;background:#1f9d67;box-shadow:0 0 0 6px rgba(31,157,103,.13)} .pptx-error{box-sizing:border-box;width:min(680px,calc(100% - 32px));margin:48px auto;padding:24px;border:1px solid rgba(28,43,58,.12);border-radius:14px;background:#fff;color:#1f2d3b;box-shadow:0 16px 42px rgba(25,42,54,.08)} +.pptx-slideshow-button{position:sticky;top:12px;z-index:4;float:right;display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;padding:8px 14px;border:1px solid rgba(42,94,144,.16);border-radius:999px;background:rgba(255,255,255,.94);color:#2a5e90;font:13px/1.2 inherit;cursor:pointer;box-shadow:0 10px 26px rgba(24,44,64,.12)} +.pptx-slideshow-button:hover{border-color:rgba(42,94,144,.34);color:#1d4a75} +.pptx-slideshow-button:focus-visible{outline:2px solid #2a5e90;outline-offset:2px} +.pptx-slideshow-button[hidden]{display:none!important} +.pptx-slideshow-button-glyph{font-size:11px;line-height:1} +.pptx-slideshow-button-key{padding:1px 6px;border-radius:6px;background:rgba(42,94,144,.1);font-size:11px;letter-spacing:.04em} +[data-viewer-theme='dark'] .pptx-slideshow-button{border-color:rgba(148,163,184,.2);background:rgba(15,23,42,.92);color:#cbd5e1} +[data-viewer-theme='dark'] .pptx-slideshow-button-key{background:rgba(148,163,184,.16)} .pptx-error strong{display:block;margin-bottom:10px;font-size:18px} .pptx-error p{margin:0;color:#607282;line-height:1.7} [data-viewer-theme='dark'] .pptx-viewer-shell{background:var(--file-viewer-render-surface-background,#101820);color:#e5eef8} @@ -59,6 +67,13 @@ const pptxPrintStyle = ` } `; +const SLIDESHOW_HOTKEY_LABEL = 'F5 / P'; + +// With several viewers on one page each renderer installs a document-level +// F5/P listener. Keep one explicitly activated shell per document so shortcuts +// never leak across viewers, host controls, or iframe documents. +const activePptxShells = new WeakMap(); + const createStyle = (documentRef: Document) => { const style = documentRef.createElement('style'); style.textContent = pptxStyle; @@ -395,9 +410,83 @@ export default async function renderPptx( const errorText = createElement(documentRef, 'p'); error.append(errorTitle, errorText); + const slideshowButton = createElement(documentRef, 'button', 'pptx-slideshow-button'); + slideshowButton.type = 'button'; + slideshowButton.hidden = true; + const slideshowGlyph = createElement(documentRef, 'span', 'pptx-slideshow-button-glyph', '▶'); + const slideshowText = createElement(documentRef, 'span', undefined, t('presentation.slideshow.start')); + const slideshowKey = createElement(documentRef, 'span', 'pptx-slideshow-button-key', SLIDESHOW_HOTKEY_LABEL); + slideshowButton.append(slideshowGlyph, slideshowText, slideshowKey); + slideshowButton.addEventListener('click', () => { + void viewer?.togglePresentation(); + }); + const surface = createElement(documentRef, 'div', 'pptx-render-surface'); - shell.append(loading, error, surface); + shell.append(loading, error, slideshowButton, surface); target.replaceChildren(style, shell); + + // A viewer owns the shortcut only after the user interacts with it. Moving + // focus or the pointer back to the host page releases that ownership. + const activateShell = () => { + activePptxShells.set(documentRef, shell); + }; + const deactivateShell = (event: Event) => { + if (viewer?.presenting || activePptxShells.get(documentRef) !== shell) { + return; + } + if (event.composedPath().includes(shell)) { + return; + } + const targetElement = event.target as HTMLElement | null; + if ( + event.type === 'focusin' && + !targetElement?.matches('button, a, input, textarea, select, [contenteditable], [tabindex]') + ) { + return; + } + activePptxShells.delete(documentRef); + }; + shell.addEventListener('pointerdown', activateShell); + shell.addEventListener('focusin', activateShell); + documentRef.addEventListener('pointerdown', deactivateShell); + documentRef.addEventListener('focusin', deactivateShell); + + // F5 mirrors PowerPoint; P is the keyboard-only toggle for browsers where F5 is spoken for. + // Typing in a field must never start a slideshow, so editable targets are skipped. + const isEditableTarget = (node: EventTarget | null) => { + const element = node as HTMLElement | null; + if (!element || typeof element.closest !== 'function') { + return false; + } + return Boolean(element.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')); + }; + + const handleShortcut = (event: KeyboardEvent) => { + if (disposed || !viewer || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (event.key !== 'F5' && event.key !== 'p' && event.key !== 'P') { + return; + } + if (!shell.isConnected || isEditableTarget(event.target)) { + return; + } + // Only the focused/last-activated shell answers, and a key pressed inside + // another shell belongs to that shell. + if (activePptxShells.get(documentRef) !== shell) { + return; + } + const targetElement = event.target as HTMLElement | null; + if (targetElement && typeof targetElement.closest === 'function') { + const targetShell = targetElement.closest('.pptx-viewer-shell'); + if (targetShell && targetShell !== shell) { + return; + } + } + event.preventDefault(); + void viewer.togglePresentation(); + }; + documentRef.addEventListener('keydown', handleShortcut); context?.registerThumbnailAdapter?.({ captureSource: 'embedded', beforeCapture: async ({ signal }) => { @@ -464,6 +553,12 @@ export default async function renderPptx( error.hidden = state !== 'error'; errorText.textContent = errorMessage; surface.classList.toggle('is-loading', state === 'loading'); + const presenting = Boolean(viewer?.presenting); + slideshowButton.hidden = state !== 'ready' || (viewer?.slideCount ?? 0) === 0; + slideshowText.textContent = presenting + ? t('presentation.slideshow.exit') + : t('presentation.slideshow.start'); + slideshowButton.setAttribute('aria-pressed', presenting ? 'true' : 'false'); }; const registerExportAdapter = () => { @@ -531,6 +626,18 @@ export default async function renderPptx( batchSize: 4, overscanViewport: 1.5, }, + presentationLabels: { + exit: t('presentation.slideshow.exit'), + hint: t('presentation.slideshow.hint'), + }, + onPresentationChange: state => { + if (!disposed) { + if (state.active) { + activePptxShells.set(documentRef, shell); + } + syncUi(); + } + }, onSlideRendered: () => notifyProgressiveReady(), onRenderComplete: () => { if (disposed || context?.signal?.aborted) { @@ -588,18 +695,36 @@ export default async function renderPptx( } }; - await openPresentation(); + const cleanup = () => { + if (disposed) { + return; + } + disposed = true; + documentRef.removeEventListener('keydown', handleShortcut); + documentRef.removeEventListener('pointerdown', deactivateShell); + documentRef.removeEventListener('focusin', deactivateShell); + shell.removeEventListener('pointerdown', activateShell); + shell.removeEventListener('focusin', activateShell); + if (activePptxShells.get(documentRef) === shell) { + activePptxShells.delete(documentRef); + } + context?.registerExportAdapter?.(null); + context?.registerThumbnailAdapter?.(null); + unregisterFileViewerZoomProvider(shell); + viewer?.destroy(); + viewer = null; + target.replaceChildren(); + }; + + try { + await openPresentation(); + } catch (error) { + cleanup(); + throw error; + } return { $el: shell, - unmount() { - disposed = true; - context?.registerExportAdapter?.(null); - context?.registerThumbnailAdapter?.(null); - unregisterFileViewerZoomProvider(shell); - viewer?.destroy(); - viewer = null; - target.replaceChildren(); - }, + unmount: cleanup, }; }