update
This commit is contained in:
79
test/test.pdf-text-selection.chromium.ts
Normal file
79
test/test.pdf-text-selection.chromium.ts
Normal file
@@ -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`
|
||||||
|
<dees-pdf-viewer
|
||||||
|
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
|
||||||
|
initialZoom="page-fit"
|
||||||
|
style="height: 600px; width: 100%;"
|
||||||
|
></dees-pdf-viewer>
|
||||||
|
`
|
||||||
|
) 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`
|
||||||
|
<dees-pdf-viewer
|
||||||
|
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
|
||||||
|
initialZoom="page-fit"
|
||||||
|
style="height: 600px; width: 100%;"
|
||||||
|
></dees-pdf-viewer>
|
||||||
|
`
|
||||||
|
) 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`
|
||||||
|
<dees-pdf-viewer
|
||||||
|
pdfUrl="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
|
||||||
|
initialZoom="page-fit"
|
||||||
|
style="height: 600px; width: 100%;"
|
||||||
|
></dees-pdf-viewer>
|
||||||
|
`
|
||||||
|
) 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();
|
||||||
@@ -52,7 +52,7 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
accessor thumbnailData: Array<{page: number, rendered: boolean}> = [];
|
accessor thumbnailData: Array<{page: number, rendered: boolean}> = [];
|
||||||
|
|
||||||
@property({ type: Array })
|
@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 pdfDocument: any;
|
||||||
private renderState: RenderState = 'idle';
|
private renderState: RenderState = 'idle';
|
||||||
@@ -63,6 +63,7 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
private currentRenderPromise: Promise<void> | null = null;
|
private currentRenderPromise: Promise<void> | null = null;
|
||||||
private thumbnailRenderTasks: any[] = [];
|
private thumbnailRenderTasks: any[] = [];
|
||||||
private pageRenderTasks: Map<number, any> = new Map();
|
private pageRenderTasks: Map<number, any> = new Map();
|
||||||
|
private textLayerRenderTasks: Map<number, any> = new Map();
|
||||||
private canvas: HTMLCanvasElement | undefined;
|
private canvas: HTMLCanvasElement | undefined;
|
||||||
private ctx: CanvasRenderingContext2D | undefined;
|
private ctx: CanvasRenderingContext2D | undefined;
|
||||||
private viewerMain: HTMLElement | null = null;
|
private viewerMain: HTMLElement | null = null;
|
||||||
@@ -230,6 +231,7 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
<div class="page-wrapper" data-page="${item.page}">
|
<div class="page-wrapper" data-page="${item.page}">
|
||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
<canvas class="page-canvas" data-page="${item.page}"></canvas>
|
<canvas class="page-canvas" data-page="${item.page}"></canvas>
|
||||||
|
<div class="text-layer" data-page="${item.page}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -330,7 +332,8 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
this.pageData = Array.from({length: this.totalPages}, (_, i) => ({
|
this.pageData = Array.from({length: this.totalPages}, (_, i) => ({
|
||||||
page: i + 1,
|
page: i + 1,
|
||||||
rendered: false,
|
rendered: false,
|
||||||
rendering: false
|
rendering: false,
|
||||||
|
textLayerRendered: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Set loading to false to render the pages
|
// Set loading to false to render the pages
|
||||||
@@ -476,6 +479,9 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
pageInfo.rendering = false;
|
pageInfo.rendering = false;
|
||||||
this.pageRenderTasks.delete(pageNum);
|
this.pageRenderTasks.delete(pageNum);
|
||||||
|
|
||||||
|
// Render text layer for selection
|
||||||
|
await this.renderTextLayer(pageNum);
|
||||||
|
|
||||||
// Update page data to reflect rendered state
|
// Update page data to reflect rendered state
|
||||||
this.requestUpdate('pageData');
|
this.requestUpdate('pageData');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -487,6 +493,63 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async renderTextLayer(pageNum: number): Promise<void> {
|
||||||
|
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 = () => {
|
private handleScroll = () => {
|
||||||
// Throttle scroll events
|
// Throttle scroll events
|
||||||
if (this.scrollThrottleTimeout) {
|
if (this.scrollThrottleTimeout) {
|
||||||
@@ -771,6 +834,7 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
this.pageData.forEach(page => {
|
this.pageData.forEach(page => {
|
||||||
page.rendered = false;
|
page.rendered = false;
|
||||||
page.rendering = false;
|
page.rendering = false;
|
||||||
|
page.textLayerRendered = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cancel any ongoing render tasks
|
// Cancel any ongoing render tasks
|
||||||
@@ -783,6 +847,16 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
});
|
});
|
||||||
this.pageRenderTasks.clear();
|
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
|
// Request update to re-render pages
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
@@ -996,6 +1070,16 @@ export class DeesPdfViewer extends DeesElement {
|
|||||||
});
|
});
|
||||||
this.pageRenderTasks.clear();
|
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
|
// Cancel any thumbnail render tasks
|
||||||
for (const task of (this.thumbnailRenderTasks || [])) {
|
for (const task of (this.thumbnailRenderTasks || [])) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ export const viewerStyles = [
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-canvas {
|
.page-canvas {
|
||||||
@@ -284,6 +285,48 @@ export const viewerStyles = [
|
|||||||
image-rendering: crisp-edges;
|
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 {
|
.pdf-viewer.with-sidebar .viewer-main {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user