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; }