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"
>
-
+
+
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 @@
-
-
-
+
+
+
+
-
-
-
-
- {{ title }}
-
-
-
-
-
-
-
-
-
-
-
+ {{ title }}
+
+
+
+
+
+
+
@@ -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"
>
-
+
@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/SafeHtmlTable.vue b/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue
index 136eaf3e716..b2647cbfaeb 100644
--- a/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue
+++ b/packages/kolibri-common/components/SafeHTML/SafeHtmlTable.vue
@@ -3,6 +3,7 @@
+ /* Negative margins cancel the extra width, so wide tables scroll full-bleed. */
.table-container {
- margin: 1em 0;
+ width: calc(100% + 32px);
+ padding: 0 16px;
+ margin: 1em -16px;
overflow-x: auto;
}
+ .table-container:focus-visible {
+ outline: 3px solid var(--table-focus-outline) !important;
+ outline-offset: -3px !important;
+ }
+
+ table.safe-html {
+ min-width: 640px;
+ margin: 16px auto;
+ font-size: 16px;
+ table-layout: fixed;
+ border-collapse: collapse;
+ }
+
+ /* Slot content carries the parent's scope id, not this component's. */
+ /deep/ caption.safe-html {
+ margin: 0 auto 12px;
+ font-weight: 600;
+ }
+
+ /deep/ caption.safe-html.small-window {
+ text-align: start;
+ }
+
+ /deep/ thead.safe-html {
+ font-weight: 600;
+ }
+
+ /deep/ th.safe-html {
+ font-weight: 600;
+ text-align: left;
+ }
+
+ /deep/ th.safe-html,
+ /deep/ td.safe-html {
+ min-width: 200px;
+ padding: 16px;
+ }
+
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..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;
@@ -172,6 +130,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');
+ });
+});
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);