From ea37d02e37c2450d8fa0355eb580efde47682df4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sat, 18 Jul 2026 14:54:29 -0700 Subject: [PATCH 1/4] Add shared ViewerToolbar and EmbeddedReadCard components ViewerToolbar is a reusable viewer chrome bar built on BaseToolbar, for adoption across the content viewers. EmbeddedReadCard frames embedded read previews in a bordered card. SafeHTML now wraps embedded ContentViewers in a layout container so they render consistently within prose. --- .../components/EmbeddedReadCard.vue | 133 ++++++++++++++++++ .../SafeHTML/__tests__/SafeHTML.spec.js | 14 ++ .../components/SafeHTML/index.js | 4 + .../components/SafeHTML/style.scss | 6 + .../components/ViewerToolbar.vue | 109 ++++++++++++++ .../__tests__/EmbeddedReadCard.spec.js | 76 ++++++++++ .../__tests__/ViewerToolbar.spec.js | 62 ++++++++ 7 files changed, 404 insertions(+) create mode 100644 packages/kolibri-common/components/EmbeddedReadCard.vue create mode 100644 packages/kolibri-common/components/ViewerToolbar.vue create mode 100644 packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js create mode 100644 packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js diff --git a/packages/kolibri-common/components/EmbeddedReadCard.vue b/packages/kolibri-common/components/EmbeddedReadCard.vue new file mode 100644 index 00000000000..1f7ac6bb255 --- /dev/null +++ b/packages/kolibri-common/components/EmbeddedReadCard.vue @@ -0,0 +1,133 @@ + + + + + + + diff --git a/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js index 550d8d72025..27ad58e512e 100644 --- a/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js +++ b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js @@ -377,6 +377,20 @@ describe('SafeHTML', () => { expect(container.querySelector('video')).not.toBeInTheDocument(); }); + it('wraps the ContentViewer in an embedded layout container', () => { + kolibri.canHandleElement = jest.fn().mockReturnValue(true); + + const { container } = render(SafeHTML, { + props: { + html: '', + }, + }); + + expect( + container.querySelector('.embedded-content-viewer > [data-testid="content-viewer"]'), + ).toBeInTheDocument(); + }); + it('renders original element when canHandleElement returns false', () => { kolibri.canHandleElement = jest.fn().mockReturnValue(false); diff --git a/packages/kolibri-common/components/SafeHTML/index.js b/packages/kolibri-common/components/SafeHTML/index.js index 22a6aac080c..7c90352f47d 100644 --- a/packages/kolibri-common/components/SafeHTML/index.js +++ b/packages/kolibri-common/components/SafeHTML/index.js @@ -162,6 +162,10 @@ export function createSafeHTML(customComponents = {}, { allowedOrigins } = {}) { }, mapChildren(node.childNodes), ); + // Wrap embedded ContentViewers in a layout container + if (component === 'ContentViewer') { + return h('div', { class: 'embedded-content-viewer' }, [childVNode]); + } return childVNode; } diff --git a/packages/kolibri-common/components/SafeHTML/style.scss b/packages/kolibri-common/components/SafeHTML/style.scss index 5c0b04ba676..84a1a452577 100644 --- a/packages/kolibri-common/components/SafeHTML/style.scss +++ b/packages/kolibri-common/components/SafeHTML/style.scss @@ -172,6 +172,12 @@ img.safe-html { opacity: 1; } +.embedded-content-viewer { + max-width: 960px; + margin: 40px auto; + overflow: hidden; +} + math.safe-html { @include text-style(normal, 32px, 130%); diff --git a/packages/kolibri-common/components/ViewerToolbar.vue b/packages/kolibri-common/components/ViewerToolbar.vue new file mode 100644 index 00000000000..203b78a63fe --- /dev/null +++ b/packages/kolibri-common/components/ViewerToolbar.vue @@ -0,0 +1,109 @@ + + + + + + + diff --git a/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js b/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js new file mode 100644 index 00000000000..8e399d6d4c2 --- /dev/null +++ b/packages/kolibri-common/components/__tests__/EmbeddedReadCard.spec.js @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { coreString } from 'kolibri/uiText/commonCoreStrings'; +import EmbeddedReadCard from '../EmbeddedReadCard'; + +function renderCard(props = {}) { + return render(EmbeddedReadCard, { + props: { + active: false, + ...props, + }, + slots: { + default: 'Viewer', + }, + }); +} + +describe('EmbeddedReadCard', () => { + it('renders its slot content when inactive', () => { + renderCard(); + screen.getByTestId('viewer'); + }); + + it('renders its slot content when active', () => { + renderCard({ active: true }); + screen.getByTestId('viewer'); + }); + + it('exposes no button affordance when inactive', () => { + renderCard(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('exposes the card as a labelled button when active', () => { + renderCard({ active: true }); + screen.getByRole('button', { name: coreString('read') }); + }); + + it('emits read when the active card is clicked', async () => { + const { emitted } = renderCard({ active: true }); + await userEvent.click(screen.getByRole('button', { name: coreString('read') })); + expect(emitted().read).toBeTruthy(); + }); + + it('does not emit read when the inactive card is clicked', async () => { + const { container, emitted } = renderCard(); + await fireEvent.click(container.firstChild); + expect(emitted().read).toBeUndefined(); + }); + + it('emits read on enter and space when active', async () => { + const { emitted } = renderCard({ active: true }); + const card = screen.getByRole('button', { name: coreString('read') }); + card.focus(); + await userEvent.keyboard('{enter}'); + await userEvent.keyboard(' '); + expect(emitted().read).toHaveLength(2); + }); + + it('takes the wrapped content out of the tab order and a11y tree when active', () => { + renderCard({ active: true }); + expect(screen.getByTestId('viewer').parentElement).toHaveAttribute('inert'); + }); + + it('leaves the wrapped content interactive when inactive', () => { + renderCard(); + expect(screen.getByTestId('viewer').parentElement).not.toHaveAttribute('inert'); + }); + + it('does not emit read on enter or space when inactive', async () => { + const { container, emitted } = renderCard(); + await fireEvent.keyDown(container.firstChild, { key: 'Enter' }); + await fireEvent.keyDown(container.firstChild, { key: ' ' }); + expect(emitted().read).toBeUndefined(); + }); +}); diff --git a/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js new file mode 100644 index 00000000000..24734a47b7c --- /dev/null +++ b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js @@ -0,0 +1,62 @@ +import { render, screen, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import ViewerToolbar, { viewerToolbarStrings } from '../ViewerToolbar'; + +const { enterFullscreen$, exitFullscreen$ } = viewerToolbarStrings; + +function renderToolbar(props = {}, slots = {}) { + return render(ViewerToolbar, { + props: { + isInFullscreen: false, + ...props, + }, + slots, + }); +} + +describe('ViewerToolbar', () => { + it('should mount', () => { + const { container } = renderToolbar(); + expect(container.firstChild).toBeTruthy(); + }); + + it('shows enter fullscreen button when not in fullscreen', () => { + renderToolbar({ isInFullscreen: false }); + screen.getByRole('button', { name: enterFullscreen$() }); + }); + + it('shows exit fullscreen button when in fullscreen', () => { + renderToolbar({ isInFullscreen: true }); + screen.getByRole('button', { name: exitFullscreen$() }); + }); + + it('emits toggleFullscreen when fullscreen button is clicked', async () => { + const { emitted } = renderToolbar(); + await userEvent.click(screen.getByRole('button', { name: enterFullscreen$() })); + expect(emitted().toggleFullscreen).toBeTruthy(); + }); + + it('renders left slot content', () => { + const { container } = renderToolbar( + {}, + { left: 'Left' }, + ); + within(container.querySelector('.toolbar-left')).getByTestId('left-content'); + }); + + it('renders center slot content', () => { + const { container } = renderToolbar( + {}, + { center: 'Center' }, + ); + within(container.querySelector('.toolbar-center')).getByTestId('center-content'); + }); + + it('renders right slot content', () => { + const { container } = renderToolbar( + {}, + { right: 'Right' }, + ); + within(container.querySelector('.toolbar-right')).getByTestId('right-content'); + }); +}); From 18f1acf3d4998dc255a233dac4dd5b8c95a498e2 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sat, 18 Jul 2026 14:54:46 -0700 Subject: [PATCH 2/4] Integrate shared viewer chrome across viewers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke toolbars in the bloompub, epub, html5, and pdf viewers with the shared ViewerToolbar, and adopt EmbeddedReadCard for the embedded read preview in the epub and pdf viewers. Four epub changes ride along: - `BottomBar` absorbs the `0 40px` horizontal padding deleted from `EpubRendererIndex`'s scoped block — same-specificity scoped rules whose winner depended on bundle order. Vertical padding stays the child's `8px 0`. - `TocButton`/`SettingsButton`/`SearchButton` drop `size="small"` to match the other toolbar buttons. - The side bars lose their in-`FocusLock` toggle copies, so the top bar's toggles take the click; `handleMouseDown` now ignores events inside the top bar to stop close-then-reopen. - `SearchSideBar`'s input swaps `@keyup.esc.stop` for `@keydown.esc.prevent`, so Esc closes the search side bar from the input as it already did for the others, without also firing the search field's native clear. --- .../frontend/views/BloomPubRendererIndex.vue | 56 +-- .../epub_viewer/frontend/views/BottomBar.vue | 2 +- .../frontend/views/EpubRendererIndex.vue | 370 +++++++++--------- .../frontend/views/SearchButton.vue | 1 - .../frontend/views/SearchSideBar.vue | 3 +- .../frontend/views/SettingsButton.vue | 1 - .../epub_viewer/frontend/views/TocButton.vue | 1 - .../epub_viewer/frontend/views/TopBar.vue | 104 ++--- .../views/__tests__/SearchSideBar.spec.js | 35 +- .../frontend/views/__tests__/TopBar.spec.js | 5 +- .../frontend/views/Html5AppRendererIndex.vue | 56 +-- .../frontend/views/PdfRendererIndex.vue | 365 +++++++++-------- 12 files changed, 468 insertions(+), 531 deletions(-) diff --git a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue index 4d75bdaafd2..1e83607bdfd 100644 --- a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue +++ b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue @@ -6,28 +6,10 @@ :style="{ width: iframeWidth }" @changeFullscreen="isInFullscreen = $event" > -
- - - - {{ fullscreenText }} - -
+
@import '~kolibri-design-system/lib/styles/definitions'; - $frame-topbar-height: 37px; - - .fullscreen-header { - text-align: right; - } - - .fs-icon { - position: relative; - top: 8px; - width: 24px; - height: 24px; - } + $frame-topbar-height: 48px; .bloompub-viewer { position: relative; diff --git a/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue b/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue index dee4a2f4c2a..5f0a5381c1e 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/BottomBar.vue @@ -100,7 +100,7 @@ .bottom-bar { height: 54px; - padding: 8px 8px 0; + padding: 8px 40px 0; overflow: hidden; text-align: center; } diff --git a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue index 61b87557d9c..c3742753054 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue @@ -1,163 +1,157 @@ @@ -172,6 +166,7 @@ import Lockr from 'lockr'; import FocusLock from 'vue-focus-lock'; import CoreFullscreen from 'kolibri-common/components/CoreFullscreen'; + import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import useContentViewer from 'kolibri/composables/useContentViewer'; import { ref, computed } from 'vue'; @@ -185,9 +180,6 @@ import BottomBar from './BottomBar'; import PreviousButton from './PreviousButton'; import NextButton from './NextButton'; - import TocButton from './TocButton'; - import SettingsButton from './SettingsButton'; - import SearchButton from './SearchButton'; import { THEMES, isDarkColor, DEFAULT_LINK_COLOR, resolveTheme } from './EpubConstants'; @@ -209,6 +201,7 @@ name: 'EpubRendererIndex', components: { CoreFullscreen, + EmbeddedReadCard, TopBar, TableOfContentsSideBar, SettingsSideBar, @@ -218,9 +211,6 @@ PreviousButton, NextButton, FocusLock, - TocButton, - SettingsButton, - SearchButton, LoadingError, }, setup(props, context) { @@ -245,6 +235,7 @@ contentDirection, contentIsRtl, reportLoadingError, + embedded, } = useContentViewer(context, { defaultDuration }); return { windowIsSmall, @@ -256,6 +247,7 @@ contentDirection, contentIsRtl, reportLoadingError, + embedded, }; }, data() { @@ -365,10 +357,25 @@ searchSideBarIsOpen() { return this.sideBarOpen === SIDE_BARS.SEARCH; }, + mobileEmbedded() { + return this.embedded && this.windowIsSmall && !this.isInFullscreen; + }, epubViewerStyle() { - return { + const embeddedInline = this.embedded && !this.isInFullscreen; + const style = { backgroundColor: this.$themeTokens.surface, }; + if (embeddedInline) { + style.height = '25vh'; + if (!this.mobileEmbedded) { + // The mobile-embedded preview gets its card border from EmbeddedReadCard. + style.border = `1px solid ${this.$themeTokens.fineLine}`; + } + if (!this.windowIsSmall) { + style.minHeight = '400px'; + } + } + return style; }, navigationButtonColor() { // Choose arrow color from the actual background luminance so custom themes @@ -403,7 +410,9 @@ if (oldSideBar === SIDE_BARS.SEARCH) { this.clearMarks(); } - if (!newSideBar) { + // Esc can close a side bar and exit fullscreen at once, which unmounts + // the top bar in the mobile embedded case before we return focus to it. + if (!newSideBar && this.$refs.topBar) { switch (oldSideBar) { case SIDE_BARS.TOC: this.$refs.topBar.focusOnTocButton(); @@ -433,8 +442,7 @@ this.visitedPages = this.savedVisitedPages || {}; }, beforeMount() { - global.ePub = Epub; - this.book = new Epub(this.epubURL); + this.book = new Epub(this.epubURL, { openAs: 'epub' }); this.book.on(EVENTS.BOOK.OPEN_FAILED, err => { this.errorLoading = true; this.reportLoadingError(err); @@ -525,9 +533,6 @@ window.removeEventListener('mousedown', this.handleMouseDown, { passive: true }); clearInterval(this.updateContentStateInterval); }, - destroyed() { - delete global.ePub; - }, methods: { updateProgress() { if (this.locations.length > 0) { @@ -606,6 +611,11 @@ } }, handleMouseDown(event) { + // The top bar sits outside the content div that stops this event, so its + // toggles would close the side bar here and immediately reopen it on click. + if (this.$refs.topBar && this.$refs.topBar.$el.contains(event.target)) { + return; + } // This check is necessary because event listeners don't seem to be removed on beforeDestroy if (this.$refs.epubViewer) { let closeSideBar = false; @@ -818,47 +828,39 @@ @import '~kolibri-design-system/lib/styles/definitions'; @import './EpubStyles'; - $top-bar-height: 36px; + $top-bar-height: 48px; $bottom-bar-height: 54px; $navigation-button-small: 36px; $navigation-button-normal: 52px; .epub-viewer { position: relative; - // Counter-balance the padding to avoid unnecessary scroll + display: flex; + flex-direction: column; height: calc(100vh - 64px); - padding: 32px 24px; overflow: hidden; font-size: smaller; border-radius: $radius; } - .epub-viewer:fullscreen, - .epub-viewer.small:fullscreen { - padding: 0; + .top-bar-component { + flex-shrink: 0; } .epub-viewer-content { position: relative; - height: 100%; + flex: 1; + min-height: 0; overflow: hidden; border: solid 1px; border-radius: $radius; } - .top-bar-component { - position: absolute; - top: 0; - right: 0; - left: 0; - height: $top-bar-height; - } - .side-bar { @extend %momentum-scroll; position: absolute; - top: $top-bar-height; + top: 0; bottom: $bottom-bar-height; } @@ -870,34 +872,11 @@ right: 0; } - .toc-button { - left: 3px; - } - - .toc-button, - .settings-button, - .search-button { - position: absolute; - top: 2px; - z-index: 2; - } - - .settings-button { - right: 67px; - } - - .search-button { - // Positioned to be in the exact same spot as the TopBar's SearchButton, - // which is given opacity: 0 when this button is shown - right: 35px; - } - .bottom-bar { position: absolute; right: 0; bottom: 0; left: 0; - padding: 0 40px; } .d-t { @@ -916,7 +895,7 @@ @extend %momentum-scroll; position: absolute; - top: $top-bar-height; + top: 0; right: 0; bottom: $bottom-bar-height; left: 0; @@ -941,6 +920,17 @@ left: 0; } + .epub-viewer.no-bottom-bar { + .side-bar, + .navigation-and-epubjs { + bottom: 0; + } + + .navigation-and-epubjs { + max-height: none; + } + } + .epub-viewer.small .epubjs-navigation { width: $navigation-button-small; } diff --git a/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue b/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue index db651ef23ad..2834a926951 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/SearchButton.vue @@ -4,7 +4,6 @@ icon="search" :ariaLabel="$tr('toggleSearchSideBar')" data-testid="search button" - size="small" @click="$emit('click')" /> diff --git a/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue b/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue index 703e3def63a..85262102327 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/SearchSideBar.vue @@ -6,13 +6,14 @@ @submit.prevent="submitSearch" >
+ diff --git a/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue b/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue index dccca98cf57..2f34cf8748b 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/TocButton.vue @@ -4,7 +4,6 @@ icon="list" :ariaLabel="$tr('toggleTocSideBar')" data-testid="toc button" - size="small" @click="$emit('click')" /> diff --git a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue index e066a27518c..96eda9dbc84 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue @@ -1,66 +1,42 @@ + + + @@ -121,22 +91,6 @@ @import './EpubStyles'; - .invisible { - // When the SearchSideBar is shown, hide this SearchButton so it does not appear - // under the second SearchButton rendered inside EpubRendererIndex - opacity: 0; - } - - .top-bar { - z-index: 1; - } - - .top-bar-grid { - margin-top: 2px; - margin-right: 3px; - margin-left: 3px; - } - .top-bar-title { @include truncate-text; diff --git a/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js b/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js index 6aa998426c0..afb073fd5c1 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js +++ b/kolibri/plugins/epub_viewer/frontend/views/__tests__/SearchSideBar.spec.js @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/vue'; +import { render, screen, fireEvent } from '@testing-library/vue'; import { defineComponent, ref } from 'vue'; import SearchSideBar from '../SearchSideBar'; @@ -50,4 +50,37 @@ describe('Search side bar', () => { const input = screen.getByRole('searchbox'); expect(input).toHaveFocus(); }); + + it('should let escape from the input reach the ancestor that closes the side bar', async () => { + const closeSideBar = jest.fn(); + const Parent = defineComponent({ + components: { SearchSideBar }, + setup() { + // eslint-disable-next-line vue/no-unused-properties + return { closeSideBar }; + }, + template: `
+ +
`, + }); + + render(Parent, { attachTo: document.body }); + + await fireEvent.keyUp(screen.getByRole('searchbox'), { key: 'Escape' }); + + expect(closeSideBar).toHaveBeenCalled(); + }); + + it('should suppress the native search field clear on escape', () => { + renderComponent(); + + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + screen.getByRole('searchbox').dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); }); diff --git a/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js b/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js index 9a1aa26dd4f..8d612d3219f 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js +++ b/kolibri/plugins/epub_viewer/frontend/views/__tests__/TopBar.spec.js @@ -1,14 +1,15 @@ import { render, screen, fireEvent } from '@testing-library/vue'; import { createTranslator } from 'kolibri/utils/i18n'; +import { viewerToolbarStrings } from 'kolibri-common/components/ViewerToolbar'; import TopBar from '../TopBar'; import TocButton from '../TocButton'; import SettingsButton from '../SettingsButton'; import SearchButton from '../SearchButton'; +const { enterFullscreen$ } = viewerToolbarStrings; const { toggleTocSideBar$ } = createTranslator(TocButton.name, TocButton.$trs); const { toggleSettingsSideBar$ } = createTranslator(SettingsButton.name, SettingsButton.$trs); const { toggleSearchSideBar$ } = createTranslator(SearchButton.name, SearchButton.$trs); -const { toggleFullscreen$ } = createTranslator(TopBar.name, TopBar.$trs); function renderTopBar(props = {}) { return render(TopBar, { @@ -85,7 +86,7 @@ describe('Top bar', () => { it('emits event when fullscreen button is clicked', async () => { const { emitted } = renderTopBar(); - await fireEvent.click(screen.getByRole('button', { name: toggleFullscreen$() })); + await fireEvent.click(screen.getByRole('button', { name: enterFullscreen$() })); expect(emitted().fullscreenButtonClicked).toBeTruthy(); }); diff --git a/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue b/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue index b3de5732743..bcae0ed4fa7 100644 --- a/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue +++ b/kolibri/plugins/html5_viewer/frontend/views/Html5AppRendererIndex.vue @@ -6,28 +6,10 @@ :style="{ width: iframeWidth }" @changeFullscreen="isInFullscreen = $event" > -
- - - - {{ fullscreenText }} - -
+
@import '~kolibri-design-system/lib/styles/definitions'; - $frame-topbar-height: 37px; - - .fullscreen-header { - text-align: right; - } - - .fs-icon { - position: relative; - top: 8px; - width: 24px; - height: 24px; - } + $frame-topbar-height: 48px; .html5-viewer { position: relative; diff --git a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue index d91bba360b1..7a150de89d1 100644 --- a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue +++ b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue @@ -1,122 +1,121 @@ @@ -133,6 +132,8 @@ import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import CoreFullscreen from 'kolibri-common/components/CoreFullscreen'; + import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard'; + import ViewerToolbar from 'kolibri-common/components/ViewerToolbar'; import useContentViewer from 'kolibri/composables/useContentViewer'; import { ref, computed } from 'vue'; import '../utils/domPolyfills'; @@ -153,7 +154,9 @@ SideBar, PdfPage, CoreFullscreen, + EmbeddedReadCard, RecyclableScroller, + ViewerToolbar, }, mixins: [commonCoreStrings], setup(props, context) { @@ -202,6 +205,26 @@ outline: null, }), computed: { + // Fullscreen reached from an embedded PDF should look like standalone fullscreen. + embeddedInline() { + return this.embedded && !this.isInFullscreen; + }, + mobileEmbedded() { + return this.embeddedInline && this.windowIsSmall; + }, + pdfViewerStyle() { + const style = { + backgroundColor: this.embeddedInline ? this.$themeTokens.surface : this.$themeTokens.text, + }; + if (this.embeddedInline && !this.mobileEmbedded) { + // The mobile-embedded preview gets its card border from EmbeddedReadCard. + style.border = `1px solid ${this.$themeTokens.fineLine}`; + if (!this.windowIsSmall) { + style.minHeight = '400px'; + } + } + return style; + }, // Returns whether or not the current device is iOS. // Probably not perfect, but worked in testing. iOS() { @@ -235,9 +258,6 @@ this.visitedPages = value; }, }, - fullscreenText() { - return this.isInFullscreen ? this.$tr('exitFullscreen') : this.$tr('enterFullscreen'); - }, debouncedShowVisiblePages() { // So as not to share debounced functions between instances of the same component // and also to allow access to the cancel method of the debounced function @@ -246,6 +266,10 @@ return debounce(this.showVisiblePages, renderDebounceTime); }, screenSizeMultiplier() { + if (this.mobileEmbedded) { + // Shrink the rendered pages so the preview fits within the card. + return 1.2; + } if (this.windowIsLarge) { return 1.25; } @@ -256,7 +280,23 @@ }, }, watch: { + mobileEmbedded() { + // screenSizeMultiplier depends on mobileEmbedded; re-derive scale so + // pages re-render at the appropriate size when entering/leaving the + // preview state. + this.$nextTick(() => { + if (this.firstPageWidth) { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); + } + }); + }, recycleListIsMounted(newVal) { + // Re-fit now the scroller exists; deferred until it applies its classes. + if (newVal === true && this.firstPageWidth) { + this.$nextTick(() => { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); + }); + } // On iOS pinch zooming always targets the document no matter what. // meta viewport attrs for `user-scalable` are ignored in iOS because // Apple considered it an a11y issue not to. So pinch-zooming on iOS @@ -281,11 +321,9 @@ }, showSideBar() { this.$nextTick(() => { - if (!this.$refs.pdfContainer || !this.$refs.pdfContainer.$el) { - return; + if (this.firstPageWidth) { + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); } - const containerWidth = this.$refs.pdfContainer.$el.clientWidth; - this.scale = containerWidth / (this.firstPageWidth * this.screenSizeMultiplier); }); }, }, @@ -323,7 +361,7 @@ const viewPort = firstPage.getViewport({ scale: 1 }); this.firstPageHeight = viewPort.height; this.firstPageWidth = viewPort.width; - this.scale = this.$el.clientWidth / (this.firstPageWidth * this.screenSizeMultiplier); + this.scale = this.viewerWidth() / (this.firstPageWidth * this.screenSizeMultiplier); // init pdfPages array // ensuring that firstPageToRender is resolved so that we do not refetch the page @@ -340,7 +378,7 @@ const outline = await pdfDocument.getOutline(); this.outline = outline; - this.showSideBar = outline && outline.length > 0 && this.windowIsLarge; // Remove if other tabs are already implemented + this.showSideBar = outline && outline.length > 0 && this.windowIsLarge && !this.embedded; // Remove if other tabs are already implemented // Reduce the scale slightly if we are showing the sidebar // at first load. this.scale = this.showSideBar ? 0.75 * this.scale : this.scale; @@ -462,6 +500,14 @@ this.showPage(i); } }, + // The scroller is the box pages lay out in; before it exists, fall back to + // the viewer (never `this.$el`, which is the card wrapper, not the viewer). + viewerWidth() { + if (this.$refs.recycleList && this.$refs.recycleList.$el) { + return this.$refs.recycleList.$el.clientWidth; + } + return this.$refs.pdfViewer ? this.$refs.pdfViewer.$el.clientWidth : 0; + }, zoomIn() { this.setScale(Math.min(scaleIncrement * 20, this.scale + scaleIncrement)); }, @@ -673,18 +719,6 @@ }); }, }, - $trs: { - exitFullscreen: { - message: 'Exit fullscreen', - context: - "Learners can use the Esc key or the 'exit fullscreen' button to close the fullscreen view on the PDF Viewer.", - }, - enterFullscreen: { - message: 'Enter fullscreen', - context: - 'Learners can use the full screen button in the upper right corner to open a PDF in fullscreen view.', - }, - }, }; @@ -693,38 +727,30 @@ diff --git a/packages/kolibri-common/components/SafeHTML/style.scss b/packages/kolibri-common/components/SafeHTML/style.scss index 84a1a452577..e8bd4f42a06 100644 --- a/packages/kolibri-common/components/SafeHTML/style.scss +++ b/packages/kolibri-common/components/SafeHTML/style.scss @@ -87,49 +87,7 @@ b.safe-html { @include text-style(bold); } -.table-container { - width: calc(100% + 32px); - padding: 0 16px; - margin-left: -16px; - overflow-x: auto; -} - -.table-container:focus-visible { - outline: 3px solid rgb(51, 172, 245) !important; - outline-offset: -3px !important; -} - -table.safe-html { - min-width: 640px; - margin: 16px auto; - font-size: 16px; - table-layout: fixed; - border-collapse: collapse; -} - -caption.safe-html { - margin: 0 auto 12px; - font-weight: 600; -} - -caption.safe-html.small-window { - text-align: start; -} - -thead.safe-html { - font-weight: 600; -} - -th.safe-html { - font-weight: 600; - text-align: left; -} - -th.safe-html, -td.safe-html { - min-width: 200px; - padding: 16px; -} +// Table styles live in SafeHtmlTable, which renders them. .image-container { display: flex; From a3fa1c6be0bce91c600aafbbb021d96cde36d936 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 27 Jul 2026 16:30:39 -0700 Subject: [PATCH 4/4] Enable the safe_html5_viewer plugin by default KPUB_ZIP content has no renderer otherwise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WAmANyoPp3rQdCNRdQt4k6 --- kolibri/utils/build_config/default_plugins.py | 1 + packages/kolibri/components/internal/filePresetStrings.js | 1 + 2 files changed, 2 insertions(+) diff --git a/kolibri/utils/build_config/default_plugins.py b/kolibri/utils/build_config/default_plugins.py index ebab842e670..d2bea70f2f4 100644 --- a/kolibri/utils/build_config/default_plugins.py +++ b/kolibri/utils/build_config/default_plugins.py @@ -8,6 +8,7 @@ "kolibri.plugins.learn", "kolibri.plugins.media_player", "kolibri.plugins.pdf_viewer", + "kolibri.plugins.safe_html5_viewer", "kolibri.plugins.perseus_viewer", "kolibri.plugins.qti_viewer", "kolibri.plugins.setup_wizard", diff --git a/packages/kolibri/components/internal/filePresetStrings.js b/packages/kolibri/components/internal/filePresetStrings.js index f3daeedfc6a..5b83464c37e 100644 --- a/packages/kolibri/components/internal/filePresetStrings.js +++ b/packages/kolibri/components/internal/filePresetStrings.js @@ -24,6 +24,7 @@ const filePresetStrings = { slideshow_manifest: 'Slideshow ({fileSize})', slideshow_image: 'Slideshow image ({fileSize})', bloompub: 'Bloom Pub Document ({fileSize})', + kpub: 'Kolibri Article ({fileSize})', }; const filePresetTranslator = createTranslator('FilePresetStrings', filePresetStrings);