diff --git a/test/test.pdf-text-selection.chromium.ts b/test/test.pdf-text-selection.chromium.ts
new file mode 100644
index 0000000..971b308
--- /dev/null
+++ b/test/test.pdf-text-selection.chromium.ts
@@ -0,0 +1,79 @@
+import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
+import * as deesCatalog from '../ts_web/index.js';
+
+tap.test('PDF viewer should render text layer', async () => {
+ const viewer = await webhelpers.fixture(
+ webhelpers.html`
+
+ `
+ ) as deesCatalog.DeesPdfViewer;
+
+ // Wait for PDF to load and render
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ await viewer.updateComplete;
+
+ expect(viewer.totalPages).toBeGreaterThan(0);
+
+ const textLayer = viewer.shadowRoot?.querySelector('.text-layer[data-page="1"]');
+ expect(textLayer).toBeTruthy();
+
+ const textSpans = textLayer?.querySelectorAll('span');
+ expect(textSpans?.length).toBeGreaterThan(0);
+ console.log(`Text layer has ${textSpans?.length} spans`);
+});
+
+tap.test('Text should be selectable', async () => {
+ const viewer = await webhelpers.fixture(
+ webhelpers.html`
+
+ `
+ ) as deesCatalog.DeesPdfViewer;
+
+ // Wait for PDF to load and render
+ await new Promise(resolve => setTimeout(resolve, 5000));
+
+ const textLayer = viewer.shadowRoot?.querySelector('.text-layer[data-page="1"]');
+ const firstSpan = textLayer?.querySelector('span') as HTMLElement;
+
+ if (firstSpan?.textContent) {
+ const range = document.createRange();
+ const textNode = firstSpan.firstChild;
+ if (textNode) {
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, Math.min(5, textNode.textContent?.length || 0));
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ expect(selection?.toString().length).toBeGreaterThan(0);
+ console.log('Selected text:', selection?.toString());
+ }
+ }
+});
+
+tap.test('endOfContent element exists for selection boundary', async () => {
+ const viewer = await webhelpers.fixture(
+ webhelpers.html`
+
+ `
+ ) as deesCatalog.DeesPdfViewer;
+
+ // Wait for PDF to load and render
+ await new Promise(resolve => setTimeout(resolve, 5000));
+
+ const endOfContent = viewer.shadowRoot?.querySelector('.text-layer[data-page="1"] .endOfContent');
+ expect(endOfContent).toBeTruthy();
+});
+
+export default tap.start();
diff --git a/ts_web/elements/00group-media/dees-pdf-viewer/component.ts b/ts_web/elements/00group-media/dees-pdf-viewer/component.ts
index b4d8f22..5aea607 100644
--- a/ts_web/elements/00group-media/dees-pdf-viewer/component.ts
+++ b/ts_web/elements/00group-media/dees-pdf-viewer/component.ts
@@ -52,7 +52,7 @@ export class DeesPdfViewer extends DeesElement {
accessor thumbnailData: Array<{page: number, rendered: boolean}> = [];
@property({ type: Array })
- accessor pageData: Array<{page: number, rendered: boolean, rendering: boolean}> = [];
+ accessor pageData: Array<{page: number, rendered: boolean, rendering: boolean, textLayerRendered: boolean}> = [];
private pdfDocument: any;
private renderState: RenderState = 'idle';
@@ -63,6 +63,7 @@ export class DeesPdfViewer extends DeesElement {
private currentRenderPromise: Promise | null = null;
private thumbnailRenderTasks: any[] = [];
private pageRenderTasks: Map = new Map();
+ private textLayerRenderTasks: Map = new Map();
private canvas: HTMLCanvasElement | undefined;
private ctx: CanvasRenderingContext2D | undefined;
private viewerMain: HTMLElement | null = null;
@@ -230,6 +231,7 @@ export class DeesPdfViewer extends DeesElement {
`
@@ -330,7 +332,8 @@ export class DeesPdfViewer extends DeesElement {
this.pageData = Array.from({length: this.totalPages}, (_, i) => ({
page: i + 1,
rendered: false,
- rendering: false
+ rendering: false,
+ textLayerRendered: false,
}));
// Set loading to false to render the pages
@@ -476,6 +479,9 @@ export class DeesPdfViewer extends DeesElement {
pageInfo.rendering = false;
this.pageRenderTasks.delete(pageNum);
+ // Render text layer for selection
+ await this.renderTextLayer(pageNum);
+
// Update page data to reflect rendered state
this.requestUpdate('pageData');
} catch (error: any) {
@@ -487,6 +493,63 @@ export class DeesPdfViewer extends DeesElement {
}
}
+ private async renderTextLayer(pageNum: number): Promise {
+ const pageInfo = this.pageData.find(p => p.page === pageNum);
+ if (!pageInfo || pageInfo.textLayerRendered) return;
+
+ try {
+ const textLayerDiv = this.shadowRoot?.querySelector(
+ `.text-layer[data-page="${pageNum}"]`
+ ) as HTMLElement;
+ if (!textLayerDiv) return;
+
+ textLayerDiv.innerHTML = '';
+
+ const page = await this.pdfDocument.getPage(pageNum);
+ const textContent = await page.getTextContent();
+ const viewport = this.computeViewport(page);
+
+ // @ts-ignore - Dynamic import of pdfjs
+ const pdfjs = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.0.379/+esm');
+
+ textLayerDiv.style.width = `${viewport.width}px`;
+ textLayerDiv.style.height = `${viewport.height}px`;
+
+ const textLayerRenderTask = pdfjs.renderTextLayer({
+ textContentSource: textContent,
+ container: textLayerDiv,
+ viewport: viewport,
+ });
+
+ this.textLayerRenderTasks.set(pageNum, textLayerRenderTask);
+ await textLayerRenderTask.promise;
+
+ // Add endOfContent for selection boundary
+ const endOfContent = document.createElement('div');
+ endOfContent.className = 'endOfContent';
+ textLayerDiv.appendChild(endOfContent);
+
+ // Selection class handling
+ textLayerDiv.addEventListener('mousedown', () => {
+ textLayerDiv.classList.add('selecting');
+ const onMouseUp = () => {
+ textLayerDiv.classList.remove('selecting');
+ document.removeEventListener('mouseup', onMouseUp);
+ };
+ document.addEventListener('mouseup', onMouseUp);
+ });
+
+ pageInfo.textLayerRendered = true;
+ page.cleanup?.();
+ this.textLayerRenderTasks.delete(pageNum);
+ } catch (error: any) {
+ if (error?.name !== 'RenderingCancelledException') {
+ console.error(`Error rendering text layer for page ${pageNum}:`, error);
+ }
+ this.textLayerRenderTasks.delete(pageNum);
+ }
+ }
+
private handleScroll = () => {
// Throttle scroll events
if (this.scrollThrottleTimeout) {
@@ -771,6 +834,7 @@ export class DeesPdfViewer extends DeesElement {
this.pageData.forEach(page => {
page.rendered = false;
page.rendering = false;
+ page.textLayerRendered = false;
});
// Cancel any ongoing render tasks
@@ -783,6 +847,16 @@ export class DeesPdfViewer extends DeesElement {
});
this.pageRenderTasks.clear();
+ // Cancel text layer render tasks
+ this.textLayerRenderTasks.forEach(task => {
+ try {
+ task.cancel?.();
+ } catch (error) {
+ // Ignore cancellation errors
+ }
+ });
+ this.textLayerRenderTasks.clear();
+
// Request update to re-render pages
this.requestUpdate();
@@ -996,6 +1070,16 @@ export class DeesPdfViewer extends DeesElement {
});
this.pageRenderTasks.clear();
+ // Cancel text layer render tasks
+ this.textLayerRenderTasks.forEach(task => {
+ try {
+ task.cancel?.();
+ } catch (error) {
+ // Ignore cancellation errors
+ }
+ });
+ this.textLayerRenderTasks.clear();
+
// Cancel any thumbnail render tasks
for (const task of (this.thumbnailRenderTasks || [])) {
try {
diff --git a/ts_web/elements/00group-media/dees-pdf-viewer/styles.ts b/ts_web/elements/00group-media/dees-pdf-viewer/styles.ts
index 2d0f921..65e8472 100644
--- a/ts_web/elements/00group-media/dees-pdf-viewer/styles.ts
+++ b/ts_web/elements/00group-media/dees-pdf-viewer/styles.ts
@@ -276,6 +276,7 @@ export const viewerStyles = [
border-radius: 4px;
overflow: hidden;
display: inline-block;
+ position: relative;
}
.page-canvas {
@@ -284,6 +285,48 @@ export const viewerStyles = [
image-rendering: crisp-edges;
}
+ /* Text layer for selection */
+ .text-layer {
+ position: absolute;
+ inset: 0;
+ overflow: clip;
+ line-height: 1;
+ text-size-adjust: none;
+ forced-color-adjust: none;
+ transform-origin: 0 0;
+ z-index: 1;
+ }
+
+ .text-layer span,
+ .text-layer br {
+ color: transparent;
+ position: absolute;
+ white-space: pre;
+ cursor: text;
+ transform-origin: 0% 0%;
+ }
+
+ .text-layer ::selection {
+ background: rgba(0, 100, 200, 0.3);
+ }
+
+ .text-layer br::selection {
+ background: transparent;
+ }
+
+ .text-layer .endOfContent {
+ display: block;
+ position: absolute;
+ inset: 100% 0 0;
+ z-index: 0;
+ cursor: default;
+ user-select: none;
+ }
+
+ .text-layer.selecting .endOfContent {
+ top: 0;
+ }
+
.pdf-viewer.with-sidebar .viewer-main {
margin-left: 0;
}